Documentation

Notifications

Queue and scale

How VitNode delivers notifications to large audiences with queue tasks and cron jobs, batched delivery with saved progress, crash recovery, retention and worker settings.

Notifications run on VitNode's database-backed queue and cron. No extra service is needed, but the queue worker must run: without a working cron adapter, nothing is delivered in the background.

Queue tasks and cron jobs

All of these belong to @vitnode/core. Tasks appear in Debug Panel → Queue (/admin/core/debug/queue) and cron jobs in Debug Panel → Cron & queue (/admin/core/debug/cron).

NameKindDoes
notifications-fanoutTask, 10 attemptsDelivers one notification to its recipients, batch by batch
notifications-emailTask, 5 attemptsSends due emails and digests
notifications-cleanupTask, 3 attemptsRemoves notifications older than the retention period
notifications-scheduleCron, every 5 minutesPlans digests, recovers stalled work and queues the email task when due
notifications-cleanupCron, daily at 02:00Queues the cleanup task

A request that publishes starts delivery right after its response is sent. Anything that misses that, such as a publish from a queue task, waits for the queue worker, which runs every minute.

Batched delivery

The fan-out task walks the recipients in batches of fanoutBatchSize, 500 by default. Each batch is one short transaction that filters out deleted users, opted-out members and anyone access rejects, then writes inbox items, unread counts and immediate email deliveries, and saves its progress on the event. Realtime updates go out after the batch commits.

  • A crash loses at most one batch. The retry continues after the last saved batch.
  • A batch that runs twice changes nothing. Core keeps one receipt per notification and member, and only members without a receipt get an item.
  • Large audiences do not block the queue. One task stops after 20 seconds and queues a new task for the rest.
  • No deadlocks between writers. Every inbox write locks the affected members in user id order, so concurrent writers wait for each other instead.

The integration test fanout.load.integration.test.ts delivers one notification to 1,000 members with batches of 200, with two workers racing on the same event. It checks that every member gets exactly one item, that access runs about once per batch and that the number of queries stays below the number of recipients.

Recovery

Stuck workRecovered by
Queue task left processing by a stopped workerThe queue worker, after 15 minutes
Notification unfinished with no task leftnotifications-schedule, after 10 minutes, by queuing a new task
Email left in sendingnotifications-schedule, after 15 minutes, by setting it back to pending
Two workers on one notificationA row lock. The second waits, then continues from the saved progress

Retention

The nightly cleanup removes, in batches:

  • inbox items with no activity for retentionDays, lowering unread counts for unread ones;
  • older notifications that no inbox item points to;
  • finished email delivery records older than retentionDays;
  • pending digest email for notifications no digest picked up within 14 days. The inbox items stay.

Configure the workers

How much work each run does and how long notifications are kept are deployment settings, so they live in your API config, not in the AdminCP. Add a notifications block to buildApiConfig:

apps/api/src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'

export const vitNodeApiConfig = buildApiConfig({
  // other options unchanged
  notifications: {
    retentionDays: 30,
    fanoutBatchSize: 1000,
    emailBatchSize: 100,
    emailConcurrency: 8,
  },
})
NameTypeRequiredDefaultDescription
retentionDaysnumberNo90Days to keep notifications, from 1 to 3,650
fanoutBatchSizenumberNo500Recipients per delivery transaction, from 10 to 5,000
emailBatchSizenumberNo50Emails one worker run picks up, from 1 to 500
emailConcurrencynumberNo4Emails sent at the same time during a run, from 1 to 20

A value outside its range is clamped to the nearest limit, and a value that is not a whole number falls back to the default. Restart the API to apply a change.

The defaults suit most sites. Raise fanoutBatchSize when one announcement to a huge audience needs too many runs, and emailConcurrency when your email provider allows more parallel sends. Lower emailBatchSize when a slow provider makes runs time out.