Queueing Notifications
Queue notifications so the request that triggers them responds right away. A background worker then sends the email, writes the inbox row, or broadcasts the event.
import { notify } from '@holo-js/notifications'
import commentPosted from '../notifications/comment-posted'
await notify(recipient, commentPosted).onQueue('notifications')Before you start
Queued notifications run through Holo queues:
npx holo install queueWithout @holo-js/queue, queueing or delaying a notification throws Queued or delayed notifications require @holo-js/queue to be installed.
With the sync queue driver, queued notifications still run immediately in the same process. With an async driver, run a worker for the connection and queue you use:
npx holo queue:work --connection redis --queue notificationsHolo registers the notification delivery job for you. See Running workers.
Queue one send
Any of these queue the notification:
await notify(recipient, commentPosted).onQueue('notifications')
await notify(recipient, commentPosted).onConnection('redis')
await notify(recipient, commentPosted).delay(300)Holo queues one job per recipient per channel, so a slow email doesn't hold up the inbox row.
Delay some channels
.delay() delays every channel. .delayFor() delays just one, and wins over .delay() for that channel:
await notify(recipient, trialEnding)
.delayFor('email', 60 * 60)
await notify(recipient, trialEnding)
.delay(new Date('2026-12-01T09:00:00Z'))Delays are in seconds or a Date. A delayed channel is always queued, while channels without a delay are sent right away.
Queue a notification every time
Set queue in the definition:
export default defineNotification({
type: 'weekly-summary',
via(recipient: { readonly id: string, readonly type: string, readonly email: string }) {
return ['email', 'database']
},
queue: { connection: 'redis', queue: 'notifications', afterCommit: true },
build: {
email() {
return { subject: 'Your weekly summary' }
},
database() {
return { data: { summary: 'weekly' } }
},
},
})queue accepts:
true: queue on the default connection and queue.- An object with
connection,queue,delay,afterCommit,tries, andbackoff. - A function
(recipient, channel, context) => boolean | options, to decide per channel:
queue(recipient, channel) {
return channel === 'email' ? { queue: 'mail' } : false
},delay in the definition accepts seconds, a Date, a per-channel object such as { email: 300 }, or a function (recipient, channel, context) => seconds | Date | undefined.
Defaults in config
queue in config/notifications.ts fills in the connection, queue, afterCommit, tries, and backoff for notifications that are already queued. It doesn't queue anything by itself:
import { defineNotificationsConfig } from '@holo-js/notifications'
export default defineNotificationsConfig({
queue: {
connection: 'redis',
queue: 'notifications',
afterCommit: true,
tries: 3,
backoff: [10, 60],
},
})tries is the total number of attempts for each queued channel, and backoff is the wait before each retry, in seconds or an array of waits per retry. A worker's --tries still overrides both, as it does for every job.
Which setting wins
For each channel:
- Delay:
.delayFor(), then.delay(), then the definition'sdelay, thendelayinside the definition'squeue. - Connection and queue:
.onConnection()/.onQueue(), then the definition'squeue, thenconfig/notifications.ts. - After commit:
.afterCommit(), then the definition'squeue, thenconfig/notifications.ts(for queued channels only). - Tries and backoff: the definition's
queue, thenconfig/notifications.ts(for queued channels only). A definition'squeue.triesorqueue.backoffoverrides the config value. When neither setsbackoff, the worker's--backoffapplies.
Wait for a transaction
.afterCommit() holds the whole notification until the current transaction commits. If the transaction rolls back, nothing is sent or queued. Outside a transaction, it sends right away.
await DB.transaction(async () => {
const comment = await createComment(input)
await notify(recipientFor(comment), commentPosted).afterCommit()
})While waiting, the result has deferred: true and each channel is marked deferred.
What the worker receives
Holo runs via() and the builders when you call notify(), then stores the built message, the route, and a JSON copy of the recipient in the job. The worker sends what was stored; it doesn't call your notification again. Changes to a notification file only affect jobs queued after the deploy.
WARNING
Because the recipient is stored as JSON, methods like routeNotificationFor() and values like Date objects don't survive into the job. Routes are resolved before queueing, so delivery still works, but a custom channel that reads notifiable inside send() sees plain data.
For a queued channel, a successful result means the job was queued, not that the message arrived. Failed jobs appear in Failed jobs.