Digests and rate limits
Batch noisy events into one message, and cap how many a recipient gets in a window.
Which One
Both tame noisy notifications, in different ways. A digest collects sends over a window and delivers one combined message at the end, so nothing is lost. A rate limit drops sends past a cap, so the recipient never hears about them. Use a digest when every event matters but not one message each (“5 new comments”); use a rate limit when extra messages are simply noise, or cost money, as SMS does.
Digests
notification({
id: "comment_added",
// send count: 1
payload: { actorName: "string", postTitle: "string", count: "number" },
channels: [
inbox({ title: "{{count}} new comments on {{postTitle}}" }),
email({
subject: "{{count}} new comments on {{postTitle}}",
body: "Latest from {{actorName}}",
}),
],
digest: {
windowMs: 5 * 60_000,
render: ({ payloads, count }) => ({
...payloads[payloads.length - 1]!,
count,
}),
},
})| Option | What it does |
|---|---|
windowMs | How long to collect sends, in milliseconds. Required and positive |
render | Turns the collected payloads into the one payload the templates render. Required |
key | Splits sends into separate batches. Optional |
How it works
- The first send to a recipient opens a batch and starts its window. Later sends in the window join the batch; they do not extend it.
- Each of those
send()calls returnsdigested: trueandnotification: null. Nothing is delivered yet, including the inbox item. - When the window ends,
renderreceives the payloads, oldest first, and their count. What it returns is validated against the payload schema like any send. - The combined payload then goes through preferences (resolved again, at that moment) and quiet hours, and is delivered on every channel.
render must be synchronous and return a plain object containing every schema field. Common patterns are “latest payload plus a count”, as above, or joining names into a string field ("Ana, Ben, and 3 others").
Batch keys
By default there is one batch per recipient and notification (and tenant and workspace, if the send is scoped). Return a string from key to split further, for example one digest per post:
digest: {
windowMs: 10 * 60_000,
key: ({ payload }) => payload.postId,
render: ({ payloads, count }) => ({ ...payloads[0]!, count }),
}Timers, restarts, and tests
Batches are stored through your database adapter, but the timer that flushes a batch lives in the process. If the process restarts, stored batches wait until something calls notify.flushDigests(), which delivers every open batch immediately, including ones whose window has not ended. Call it on boot if you run a persistent adapter, and in tests instead of waiting. notify.close() cancels the timers without delivering, leaving batches in the database.
A flush that fails for a temporary reason is put back and retried up to 3 times, 30 seconds apart. A render that returns something other than a plain object, or a recipient that no longer exists, drops the batch.
Rate Limits
notification({
id: "login_code",
payload: { code: "string" },
channels: [sms({ body: "Your code is {{code}}" })],
rateLimit: { max: 5, windowMs: 60 * 60_000 }, // 5 an hour per recipient
})| Option | What it does |
|---|---|
max | Sends allowed in the window. A positive integer |
windowMs | The window length. It slides: it always covers the last windowMs |
scope | "recipient" (default) counts per recipient; "global" counts all sends of this notification |
Counts are also kept separately per tenant and workspace. A send over the limit is not delivered on any channel: send() returns rateLimited: true, the record is kept with every channel skipped as rate_limited, and the notification.rate_limited hook fires. It is not queued for later.
Only sends that reach the rate-limit stage count. A duplicate dropped by deduplication, or a send suppressed because every channel is turned off, does not use up the budget. A send that is counted and then digested or held by quiet hours does.
Using Both
The rate limit is checked first, so over-limit sends never enter a digest. Together they bound both the number of events you accept and the number of messages you send:
notification({
id: "comment_added",
// ...
rateLimit: { max: 100, windowMs: 60 * 60_000 },
digest: {
windowMs: 15 * 60_000,
render: ({ payloads, count }) => ({ ...payloads[0]!, count }),
},
})| Window | Feels like | Good for |
|---|---|---|
| 1 to 5 minutes | Nearly live | Comment threads, reactions |
| 15 to 60 minutes | A periodic summary | Activity on a project |
| Several hours or more | A newsletter | Low-urgency roundups |
Debugging
explain()reportswouldRateLimitwith the current count againstmax, andwouldDigestwith the window. See Explain and dry run.- A rate-limited send has a
rate_limitedevent in its timeline. - If a digest never arrives, check that the process that buffered it is still running, or call
flushDigests().