Dispatchers
A dispatcher decides where and when a notification is processed — inline now, or on a worker later. The default is synchronous; opt into async per notification with shouldQueue and swap the driver in forRoot.
A dispatcher is one of the two pluggable abstractions in the library. Channels decide how a notification leaves; dispatchers decide where and when it runs. They are independent — see Channels & Dispatchers.
A dispatcher is small. One method:
interface DispatchDriver {
dispatch(job: NotificationJob): Promise<void>;
}A NotificationJob carries the notifiable, the notification, the resolved channel list, and an
optional queue hint. What the dispatcher does with it is up to the driver: run it inline, defer
it to a later tick, or push it to a worker on another process.
The default: synchronous
Out of the box, NotificationsModule.forRoot() wires the SyncDispatcher. It runs the channels
inline, in the current process — no queue, no serialization. The happy path passes the live
objects straight through; if a job arrives already serialized, the same dispatcher rehydrates it,
so it doubles as a worker-side runner.
Synchronous is the right default. You install, write one class, call send(), and the work
happens now. Reach for an async dispatcher only when you want delivery off the request path.
Sync vs. async, per notification
A notification opts into async delivery by setting shouldQueue:
export class InvoicePaid implements Notification {
shouldQueue = true;
queue = 'mail'; // optional hint passed through to the dispatcher
via(): string[] {
return ['mail'];
}
}NotificationService.send() reads shouldQueue and routes accordingly:
shouldQueuefalsy (default) → channels run inline.shouldQueue = true→ the job goes to the configured dispatcher.
You can also force a route regardless of the flag:
sendNow(notifiable, notification)— always inline.sendAsync(notifiable, notification)— always through the configured dispatcher.
The call site never changes. Switching a notification from sync to queued changes where it runs, never how it's delivered — both ends run the same channel code.
Overriding the dispatcher
Pass a dispatcher class to forRoot. Supply its dependencies through imports and providers:
NotificationsModule.forRoot({
dispatcher: EventEmitterDispatcher,
});The SyncDispatcher stays registered even when you override it, so a custom dispatcher can
delegate to inline delivery if it wants. Use forRootAsync when the configuration (or
resolveNotifiable) needs injected dependencies.
The dispatcher is resolved through the NOTIFICATION_DISPATCHER token. A custom dispatcher is
just a provider that implements DispatchDriver and is passed as the dispatcher option — wire
any transport you like.
Choosing a dispatcher
Four dispatchers ship with the library. The split that matters: in-process drivers pass live
objects and never serialize; cross-process drivers serialize the job and rehydrate it on the
worker — which means they need a notifications registry and a resolveNotifiable function.
See Async dispatch for the full round trip.
| Dispatcher | Process | Serializes | Needs resolveNotifiable |
|---|---|---|---|
| Sync (default) | same, inline | no | no |
| event-emitter | same, deferred | no | no |
| BullMQ | worker | yes | yes |
| Redis | worker | yes | yes |
- Want async without infrastructure? Use event-emitter.
- Already running BullMQ? Reuse it.
- Want a dedicated worker without BullMQ? Use the Redis dispatcher.
Push
Send push notifications through Web Push, Firebase Cloud Messaging, or Expo. Pick one transport, build a PushMessage, and route to one device token or many.
Event emitter
In-process, fire-and-forget async on a later tick. No queue, no serialization, no resolveNotifiable — the simplest way to move delivery off the request without any infrastructure.