better-push
Build notifications

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:

src/push.ts
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.

FoldA window flushes
Database only, cron-drivenat 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:

app/api/cron/route.ts
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 for key or render to work with. An ad-hoc call whose type happens 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 render returns).

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

On this page