Digests
Collapse a burst of notifications into one, with a fixed window or a sliding debounce.
Twenty people like a post. Without digests that is twenty notify() calls,
twenty notifications, twenty feed rows, and twenty pushes to every device the
author owns. This is the single most common reason a team stops using a
notification library and starts building one.
A digest turns those twenty calls into one notification. Declare it on the type:
postLiked: define<{ postId: string; postTitle: string; actor: string }>({
title: (p) => `${p.actor} liked your post`,
label: "Post likes",
group: "Social",
digest: {
debounce: "30s",
maxWait: "10m",
key: (p) => p.postId,
render: (items, { total }) => ({
title:
total === 1
? `${items[0]!.actor} liked your post`
: `${total} people liked "${items[0]!.postTitle}"`,
body:
total > 1
? `${items.map((i) => i.actor).slice(0, 3).join(", ")} and others`
: undefined,
}),
},
}),Nothing about the call site changes. notify("postLiked", { userId, payload })
still returns, but it now returns { outcome: "digested" } - the send was
collected rather than delivered.
The two shapes
Exactly one per type, enforced at construction.
A fixed window. The first event fixes the end time and later events do not move it.
digest: { window: "5m", render }Use it when the cadence should be predictable: a five-minute window sends at most one notification every five minutes, no matter how the events arrive.
A sliding debounce. Each event pushes the end out, capped by maxWait so a
chatty stream still flushes.
digest: { debounce: "30s", maxWait: "10m", render }Use it when you want to send once the burst is over. Twenty likes arriving over
a minute produce one notification thirty seconds after the last one - not four
notifications on window boundaries. maxWait is measured from the first event
and is required: without it a stream of one event every twenty-nine seconds
would never flush at all.
The grouping key
key splits a type's windows by subject, so you get one digest per post rather
than one per user:
digest: { debounce: "30s", maxWait: "10m", key: (p) => p.postId, render }Without it there is a single window per (user, type) and likes on two
different posts merge into one notification. With it, they are two.
Keep the cardinality low
A key that is unique per event - a timestamp, a comment id - produces a digest per event, which digests nothing and writes a window row for every send. The key should name the subject people are reacting to, not the reaction.
The key is available to render as context.key, which is usually what a
data.url should point at:
render: (items, { total, key }) => ({
title: `${total} people liked your post`,
data: { url: `/posts/${key}` },
}),render
render(items, context) composes the one notification the window becomes. It
runs at flush time, on the flushing process.
interface DigestRenderContext {
/** Every item appended, including any past `maxItems`. */
total: number;
/** The grouping key, or `""`. */
key: string;
userId: string;
type: string;
openedAt: Date;
flushedAt: Date;
}
interface DigestRendered {
title: string;
body?: string;
data?: Record<string, unknown>;
/** Overrides the definition's channels for this digest. */
channels?: Channel[];
}A single-item window still renders. There is no "skip the digest when only
one thing happened" mode, because it would mean two code paths and two possible
wordings for the same event. Handle total === 1 yourself - it is one ternary,
and it lets you write "Sam liked your post" instead of "1 person liked your
post".
maxItems and total
items is capped, total is not. Past the cap (100 by default, or
digest.maxItems per type, or digest: { maxItems } instance-wide) items are
counted but not stored, so one window can never grow into an unbounded document.
render: (items, { total }) => ({
// `total` is honest even when `items` stopped growing.
title: `${total} people liked your post`,
// `items` is the sample you can name.
body: items.slice(0, 3).map((i) => i.actor).join(", "),
}),What flushes a window
The window row in your Postgres is the source of truth. The queue only supplies an alarm clock, so a lost or duplicated alarm can neither lose nor duplicate a digest.
| Fold | A window flushes |
|---|---|
| Database only, cron-driven | at the next push.flushDigests() or POST /_internal/run-pending |
queue: dbQueue() | within one poll interval (~1s) of its due time |
queue: bullmq() | at its due time, on a native delayed job |
With no queue at all, call it from cron:
const stats = await push.flushDigests();
// -> { flushed, rearmed, failed, remaining }A running worker sweeps for overdue windows on its own every 15 seconds, so a
window whose alarm was lost - a flushed Redis, a bp_job row deleted by hand, a
crash between the append and the enqueue - is still delivered. That sweep is
skipped entirely when no type declares a digest.
Preferences and the feed
Preferences are resolved at flush time, by the same code an immediate send
uses. A user who turns push off while a window is open gets a suppressed
delivery row and no push, and their in-app feed entry still appears if in-app is
on.
The feed gets exactly one entry, and one realtime created signal - from the
feed's point of view a digest is one new notification, which is the whole point.
Reading the result
const result = await push.notify("postLiked", { userId, payload });
if (result.outcome === "digested") {
result.windowId; // the window this landed in
result.windowEndsAt; // when it is currently expected to flush
result.itemCount; // items in the window, including this one
}windowEndsAt is a snapshot: on a debounce, the next event moves it.
Concurrency
An append is a single INSERT ... ON CONFLICT DO UPDATE against a partial
unique index. Twenty likes are twenty cheap upserts onto one row, and two app
instances appending at the same moment is a database-level guarantee rather than
application logic.
The uniqueness covers windows that are open and unclaimed, so an event arriving while a window is being rendered opens a fresh window instead of being swallowed by a digest whose items have already been read. A flush takes a lease; a worker that dies mid-render releases the window instead of stranding its items.
When a digest cannot be rendered
A render that throws releases the claim, records last_error, and retries
with backoff. After digest.maxFlushAttempts (default 3) the window is closed
with no notification, logged as an error, and reported through the
digest.failed event. A deterministically broken render gives up visibly
rather than being swept forever.
What digests do not do
- No digest for the ad-hoc
notify({...})form. Digest config lives on a definition, and an ad-hoc send has no payload forkeyorrenderto work with. An ad-hoc call whosetypehappens to match a digested definition delivers immediately. - No cross-type digests. One window is one type.
- No per-channel digests. A window renders one notification, delivered on
the channels its definition declares (or the ones
renderreturns).
Options
betterPush({
digest: {
/** Default item cap for types that do not set one. Default 100. */
maxItems: 100,
/** Flush lease in ms. Default 60000. A slower render risks a double flush. */
leaseMs: 60_000,
/** Failed flushes before a window gives up. Default 3. */
maxFlushAttempts: 3,
/** Windows the worker sweep flushes per tick. Default 50. */
sweepBatch: 50,
},
retention: {
/** Flushed windows are history; nothing is deleted without this. */
digestWindows: "30d",
},
});Events
betterPush({
onEvent: (event) => {
if (event.type === "digest.flushed") {
// { windowId, userId, notificationType, key, total, notificationId }
}
if (event.type === "digest.failed") {
// { windowId, userId, notificationType, key, total, attempts, error }
}
},
});Digest interfaces
Prop
Type
Prop
Type