Caveats
Postboy is strictly typed, but a few runtime semantics still surprise new users: routing by ID string rather than class identity, synchronous exec, lazy callback dispatch, and silent locking. Each entry below gives the symptom, the cause, and the fix.
Inherited static ID causes silent cross-talk
Symptom. Subscribers of one message class receive deliveries intended for another; registrations override each other unexpectedly.
Cause. Routing is keyed by the static ID string, not by class identity. A subclass that does not declare its own ID inherits the parent's, so both classes resolve to the same registration.
Fix. Declare a unique static readonly ID on every concrete message and executor class. See Concepts and Registration.
Missing static ID throws in checkId
Symptom. "<ClassName> should have a static ID field" is thrown at registration or subscription time.
Cause. The bus validates every message constructor with checkId; an instance field or a plain property is not enough — it must be static readonly ID.
Fix. Add static readonly ID = 'some.unique.value'; to the class. Prefer dotted, prefixed IDs to avoid collisions.
fire or exec on an unregistered ID throws
Symptom. There is no registered event ... from fire/sub, or There is no registered executor with id ... from exec.
Cause. The store keeps one registration per ID; nothing is auto-registered. Firing before registration, or after a DisconnectMessage, throws.
Fix. Register in up() or module init and subscribe afterwards; where registration is not guaranteed, wrap the call in try/catch. See Best Practices and How It Works.
sub() returns an Observable, never a Subject
Symptom. Calling .next(...) on the result of sub() fails or silently does nothing.
Cause. sub(type) hands out an Observable<T> view of the one registered subject. Emission is the producer's job.
Fix. Emit with postboy.fire(new MyMessage(...)) and let the registered subject drive the stream. See Events.
exec is synchronous — never await it
Symptom. await postboy.exec(...) looks asynchronous but resolves immediately to the plain value; wrapping the result in Promise or Observable helpers breaks typing and adds nothing.
Cause. exec invokes the registered handler synchronously and returns T directly.
Fix. Use exec for synchronous commands. For anything asynchronous, define a PostboyCallbackMessage and consume it with fireCallback. See Executors and Callbacks.
fireCallback without action dispatches lazily
Symptom. The responder never runs even though fireCallback was called; the request is only sent once someone subscribes.
Cause. Without the optional action argument, dispatch happens on the first subscription to the returned Observable. Dispatch occurs at most once per call, no matter how many subscriptions follow.
Fix. Pass action (or subscribe immediately) when the request must be sent right away:
See Callbacks and Callback Patterns.
Locked messages are silently dropped
Symptom. A message is fired but no subscriber receives it — it looks like data loss, and no error is thrown.
Cause. A type locked via exec(new LockMessage(Type)) has delivery silently skipped by fire and fireCallback. Middleware hooks still run and exec is unaffected.
Fix. Treat a locked message as a no-op, not an error; unlock with UnlockMessage to resume delivery. Never lock infrastructure IDs you do not own. See Infrastructure Messages.
PostboyContextService and isCancelError are not exported
Symptom. Importing them from @artstesh/postboy fails to compile or resolves to undefined.
Cause. Both exist in the sources but are not exported from the package root (PostboyContextService also relies on node:async_hooks, server-only).
Fix. Detect cancellations with error instanceof CancelError or error.name === 'PostboyCancelError', and read error.details. Do not build on the context service. See Middleware Cancellation.
Deprecated and removed service methods
Symptom. Calls such as postboy.lock(...), postboy.addMiddleware(...), or postboy.addNamespace(...) do not exist in 3.5.x; postboy.record(...) and friends compile but are marked @deprecated.
Cause. Since v3 every bus mutation goes through infrastructure messages; the direct mutators were removed in 3.5, and the record* methods on PostboyService remain only for migration.
Fix. Use the replacements: exec(new ConnectMessage/ConnectExecutor/ConnectHandler(...)) instead of record*; exec(new LockMessage/UnlockMessage/AddMiddleware/RemoveMiddleware/AddNamespace/EliminateNamespace/DisconnectMessage(...)) for the rest. The chainable record* methods on PostboyAbstractRegistrator are not deprecated. See Registration, Infrastructure Messages, Lifecycle Management, and Migrating Middleware from 3.4.
dispose() ends the bus, not just your feature
Symptom. After postboy.dispose(), every fire, sub, and exec — including infrastructure messages — fails.
Cause. dispose() eliminates all namespaces, completes every remaining subscription and pending callback result, disposes every middleware, and clears the infrastructure handlers.
Fix. Call dispose() only when tearing the whole bus down (for example at test or application shutdown), then construct a new PostboyService. For feature-level teardown use registrator.down() or EliminateNamespace. See Lifecycle Management and Namespaces.
Quick reference
Surprise | What to do |
|---|---|
Subclass receives parent's messages | Give every class its own |
"should have a static ID field" | Declare |
"There is no registered event/executor" | Register before fire/exec; guard optional wiring |
| Emit via |
|
|
Callback request never sent | Pass |
Message fired but not delivered | Check for |
Import of | Use |
| Use |
Bus dead after | Create a new |
Next steps
Best Practices — the rules that prevent most of these pitfalls.
Middleware Cancellation — working with
CancelError.Migrating Middleware from 3.4 — the 3.5 pipeline changes.
API Reference — full signatures.