nestjs-notifications
Dispatchers

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 required method, plus an optional bulk one:

interface DispatchDriver {
  dispatch(job: NotificationJob): Promise<void>;
  dispatchMany?(jobs: NotificationJob[]): Promise<void>;
  readonly transactional?: boolean; // honors job.transaction (see the outbox dispatcher)
}

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:

invoice-paid.notification.ts
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:

  • shouldQueue falsy (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:

app.module.ts
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.

Large sends

One send() to many recipients (or to many tenants with forTenants) runs at most 50 deliveries at a time — one per (recipient, tenant) pair — and returns the SendResults in input order. This keeps a big recipient list from opening every connection, or hitting a provider's rate limit, all at once. If one recipient's guard check or delivery throws, the send() rejects with that error, but only after every other recipient has been handled and their async jobs have reached the dispatcher. One failing recipient never stops the others.

Async jobs from one send are collected and handed to the dispatcher together, after the dispatch guards have run for every recipient:

  • If the dispatcher implements dispatchMany(jobs), it gets every job of the send in one call, in recipient order. Implement it in a custom dispatcher when the transport has a bulk enqueue (the BullMQ dispatcher does, with addBulk).
  • Otherwise each job goes through dispatch(job), at most 50 calls at a time.

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.

DispatcherProcessSerializesNeeds resolveNotifiable
Sync (default)same, inlinenono
event-emittersame, deferrednono
BullMQworkeryesyes
Redisworkeryesyes
Outboxrelay (after commit)yesyes
  • Want async without infrastructure? Use event-emitter.
  • Already running BullMQ? Reuse it.
  • Want a dedicated worker without BullMQ? Use the Redis dispatcher.
  • Sending next to a database write? Use the outbox: delivered only if the transaction commits.

On this page