BackCore concepts

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,
		}),
	},
})
OptionWhat it does
windowMsHow long to collect sends, in milliseconds. Required and positive
renderTurns the collected payloads into the one payload the templates render. Required
keySplits sends into separate batches. Optional

How it works

  1. 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.
  2. Each of those send() calls returns digested: true and notification: null. Nothing is delivered yet, including the inbox item.
  3. When the window ends, render receives the payloads, oldest first, and their count. What it returns is validated against the payload schema like any send.
  4. 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
})
OptionWhat it does
maxSends allowed in the window. A positive integer
windowMsThe 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 }),
	},
})
WindowFeels likeGood for
1 to 5 minutesNearly liveComment threads, reactions
15 to 60 minutesA periodic summaryActivity on a project
Several hours or moreA newsletterLow-urgency roundups

Debugging

  • explain() reports wouldRateLimit with the current count against max, and wouldDigest with the window. See Explain and dry run.
  • A rate-limited send has a rate_limited event in its timeline.
  • If a digest never arrives, check that the process that buffered it is still running, or call flushDigests().