Digests & rate limits

Noisy notifications are the fastest way to lose users. Digests coalesce multiple sends into one. Rate limits hard-cap delivery. Both are configured per-notification with two fields.

Digests

Buffer rapid-fire events into a single delivery. "5 new comments" instead of 5 separate emails.

Rate limits

Hard cap on sends per recipient per window. Excess is permanently dropped — a safety valve against spam loops.

Composable

Use both together: rate limits cap the buffer size, digests collapse what passes into one notification.

DigestRate limit
What it doesBatches sends into one deliveryDrops sends that exceed a threshold
Excess sends are...Buffered and included in the batchGone — permanently dropped
Use whenMultiple events should collapse (comments, likes)You need a hard safety cap

What the user experiences

Without digests, rapid-fire events flood the user. With digests, they get one concise summary. Here's the same scenario both ways:

Without digestWith 5-min digest
10:00Email: "Rey commented on Launch Plan"(buffering...)
10:01Email: "Sam commented on Launch Plan"
10:02Email: "Ava commented on Launch Plan"
10:03Email: "Rey commented on Launch Plan"
10:04Email: "Sam commented on Launch Plan"
10:05Email: "5 new comments on Launch Plan"
Inbox items still deliver individually. Digests batch push channels (email, SMS, webhook) — but each send() can still write an inbox item immediately if you configure it that way. The inbox is user-pulled, so multiple items don't "interrupt."

Digests

A digest accumulates sends within a rolling time window and flushes them as a single notification. Use it when the same event can fire many times in quick succession (comments, likes, updates).

lib/notifications/comments.ts
notification({
  id: "new_comments",
  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}}. Open the post to read them all.",
    }),
  ],
  digest: {
    windowMs: 5 * 60_000,
    key: ({ payload }) => payload.postTitle,
    render: ({ payloads, count }) => ({
      actorName: payloads[payloads.length - 1]!.actorName,
      postTitle: payloads[0]!.postTitle,
      count,
    }),
  },
})

How it works

1
First send opens the window

Creates a buffer entry with flushAt = now + windowMs. No notification delivered yet.

2
Subsequent sends accumulate

Sends with the same digest key append their payload to the buffer. Still no delivery.

3
Window expires — flush

The engine calls render() with all buffered payloads, then executes a normal send with the combined result.

What render() receives

When the window expires, the engine calls your render() function with every buffered payload. You return the single payload that gets sent:

InputExample value
payloads[{ actorName: "Rey", postTitle: "Launch", count: 1 }, { actorName: "Sam", postTitle: "Launch", count: 1 }, { actorName: "Ava", postTitle: "Launch", count: 1 }]
count3
Your return →{ actorName: "Ava", postTitle: "Launch", count: 3 }
Return shape must match payload schema. The rendered payload goes through the same validation as a normal send. If your schema requires actorName: "string", your render() must include it — even if it's just the last actor in the buffer.
Digest key scoping. The digest key function only controls grouping within the same (recipientId, notificationId, scope) boundary. Two different recipients always get separate digests.

Designing your digest key

The key function controls which sends get batched together. A bad key either groups unrelated events (confusing summaries) or splits related events (still noisy). Think of it as: "what should the user see as one notification?"

Key too broadKey too narrowKey just right
All comments → one digestEach comment → its own digest (no batching)Comments on the same post → one digest per post
"8 things happened" — user can't tell what or where8 separate emails — same as no digest"8 new comments on Launch Plan" — clear and actionable

Match the key to the entity the user cares about:

Notification typeKey returnsUser sees
Comments on a postpayload.postId"5 new comments on Launch Plan"
Reactions on any content`${payload.targetType}:${payload.targetId}`"12 reactions on your comment"
Task updates in a projectpayload.projectId"7 task updates in Backend Refactor"
New followers"followers" (constant)"4 new followers this week"
Deploy status per servicepayload.serviceName"3 deploys to api-gateway (latest: succeeded)"
lib/notifications/comments.ts
digest: {
  windowMs: 5 * 60_000,

  // Comments: group by post
  key: ({ payload }) => payload.postId,

  // Reactions: group by target
  // key: ({ payload }) => `${payload.targetType}:${payload.targetId}`,

  // Followers: constant key — all follows batch into one
  // key: () => "followers",

  render: ({ payloads, count }) => ({ /* ... */ }),
}
Don't include the actor in the key. If your key is `${postId}:${actorId}`, each commenter gets a separate digest — defeating the purpose. The actor should vary within a digest ("Rey, Sam, and 3 others"), not define the boundary.

Flush scheduling

Queue typeHow flush happensAction needed
setTimeoutQueue()Internal timer per bucketNone — automatic
inlineQueue()N/A (no background work)Call flushDigests() on a cron
Custom (BullMQ, SQS)Your workerCall flushDigests() every ~30s

Rate limits

A rate limit drops sends that exceed a threshold within a sliding window. Unlike digests, dropped sends are gone — they're not buffered.

lib/notifications/mentions.ts
notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postUrl: "string" },
  channels: [inbox({ title: "{{actorName}} mentioned you" })],
  rateLimit: {
    max: 20,
    windowMs: 60 * 60_000,
    scope: "recipient",
  },
})

Scope options

ScopeCounts
"recipient" (default)Sends to the same recipient for this notification
"global"All sends for this notification across all recipients

Evaluation order

Rate limit runs before digest. If a send exceeds the limit, it's dropped before it ever enters the digest buffer. This prevents attackers from flooding a user's digest bucket.

Combining both

Use rate limits as a safety valve and digests for the user experience. Here's what happens when a send hits a notification with both configured:

1
Rate limit check

Is the recipient under the threshold? No → permanently dropped. Yes → continue.

2
Digest buffer

Payload appended to the recipient's bucket. Returns digested: true. No delivery yet.

3
Window expires

render() combines all buffered payloads into one. A normal send executes with the combined payload.

4
Delivery

One email/notification reaches the user — regardless of how many events passed the rate limit.

With 100 sends in 1 hourRate limit: max 50No rate limit
Sends that pass50 (other 50 permanently dropped)100
Enter digest buffer50 payloads100 payloads
Digests delivered1 email: "50 new activities"1 email: "100 new activities"
User disruption1 notification1 notification
Rate limit protects your system, digest protects your user. Without the rate limit, an attacker triggering 10,000 events floods your digest buffer (wasting storage). The rate limit caps the buffer size. Without the digest, 50 individual emails still annoy the user. Together they cap both the storage cost and the user interruption.
lib/notifications/activity.ts
notification({
  id: "activity_feed",
  payload: { summary: "string", count: "number" },
  channels: [inbox({ title: "{{count}} new activities" })],
  rateLimit: { max: 100, windowMs: 60 * 60_000 },
  digest: {
    windowMs: 10 * 60_000,
    render: ({ payloads, count }) => ({
      summary: `${count} things happened`,
      count,
    }),
  },
})

Choosing a window size

Too short and you still get notification spam. Too long and users feel out of the loop. Match the window to how quickly users need to know:

WindowGood forUser experience
1–2 minChat-style apps, live collaborationNear-instant but collapses rapid-fire bursts (3 messages → 1 email)
5–10 minComments, reactions, task updatesFeels timely without being noisy. Best default for most apps.
30–60 minSocial feeds, activity digestsBatches a burst of activity into one summary. Good for "5 people liked your post."
4–24 hoursDaily summaries, weekly roundupsSingle comprehensive notification. Pair with a cron-triggered flush.
Start at 5 minutes and adjust. Monitor how many payloads end up in each digest via the count value in render(). If most digests contain only 1 item, your window is too short — you're adding latency without reducing noise.

Testing digests locally

Waiting 5+ minutes for a digest window to expire during development is painful. Use these patterns to iterate quickly:

ApproachHowBest for
Short window in devOverride windowMs to 5 seconds in developmentManual testing in the browser — see the digest arrive quickly
Manual flushCall notify.flushDigests() directly after sendsAutomated tests — deterministic, no timers involved
Explain / dry runCheck result.digested === true on the send resultVerifying the send entered the buffer without waiting for flush
lib/notifications/comments.ts
notification({
  id: "new_comments",
  // ...
  digest: {
    windowMs: process.env.NODE_ENV === "test" ? 0 : 5 * 60_000,
    key: ({ payload }) => payload.postUrl,
    render: ({ payloads, count }) => ({ /* ... */ }),
  },
})
tests/digests.test.ts
import { describe, it, expect } from "vitest"

describe("comment digest", () => {
  it("batches 3 mentions into one delivery", async () => {
    for (const actor of ["Rey", "Sam", "Ava"]) {
      const result = await testNotify.send({
        recipientId: "user_1",
        notificationId: "new_comments",
        payload: { actorName: actor, postTitle: "Launch", postUrl: "/p/1", count: 1 },
      })
      expect(result.digested).toBe(true)
      expect(result.deliveries).toHaveLength(0)
    }

    const flushed = await testNotify.flushDigests()

    expect(flushed).toHaveLength(1)
    expect(flushed[0].deliveries.length).toBeGreaterThan(0)
  })
})
Don't use windowMs: 0 in production. A zero window means every send flushes immediately — it's the same as having no digest at all. Only use it in test environments where you call flushDigests() manually.

Retrofitting digests to a live notification

You shipped a notification without a digest. Now it's noisy — users get 15 emails an hour during active threads. Adding a digest to a live notification is safe, but the deploy sequence matters.

1
Add payload fields for the digest summary

Your render() needs to return the full payload shape. If your current payload lacks a count or summary field, add it as optional first.

2
Deploy with digest config

Add digest to the notification definition. From this moment, new sends buffer instead of delivering immediately.

3
Ensure flush is running

If you use inlineQueue() or a custom queue, add a flushDigests() call to your cron. setTimeoutQueue() handles this automatically.

4
Monitor the first few windows

Watch items-per-flush in logs. If most digests contain only 1 item, the window is too short for your traffic pattern.

lib/notifications/mentions.ts
// BEFORE: direct delivery, noisy during active threads
notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postTitle: "string", postUrl: "string" },
  channels: [
    inbox({ title: "{{actorName}} mentioned you in {{postTitle}}" }),
    email({ subject: "{{actorName}} mentioned you", body: "Open {{postUrl}}" }),
  ],
})
lib/notifications/mentions.ts
// AFTER: digested — one email per 5 minutes instead of one per comment
notification({
  id: "comment_mentioned",
  payload: {
    actorName: "string",
    postTitle: "string",
    postUrl: "string",
    count: "number",
  },
  channels: [
    inbox({ title: "{{actorName}} mentioned you in {{postTitle}}" }),
    email({
      subject: "{{count}} new mentions in {{postTitle}}",
      body: "Latest from {{actorName}}. Open {{postUrl}} to catch up.",
    }),
  ],
  digest: {
    windowMs: 5 * 60_000,
    key: ({ payload }) => payload.postUrl,
    render: ({ payloads, count }) => ({
      actorName: payloads[payloads.length - 1]!.actorName,
      postTitle: payloads[0]!.postTitle,
      postUrl: payloads[0]!.postUrl,
      count,
    }),
  },
})
ConcernWhat happensAction
Sends already in flightThey don't have count in their payloadMake count optional in the schema. Handle missing values in render() with a default.
Inbox itemsInbox still delivers per-send (not batched)Expected — inbox is pull-based. Only push channels (email/SMS) digest.
Existing preferencesUser opt-outs still apply to the digested sendNo change needed — preferences resolve on the flushed send, not on buffering.
Rate limitsRate limit counts individual sends (before buffering)You may want to increase the rate limit now that digests reduce actual delivery volume.
User experience during deployFirst digest window after deploy may contain only 1 itemNormal — the window starts counting from the first send after deploy. Subsequent windows batch properly.
Update all send() callers to pass count: 1. The digest render() produces a payload with count, but individual sends (that arrive outside a window, or when only 1 event fires) also need it. Without it, a single-item "digest" renders with an empty count. Set count: 1 at every call site.
Rollback is instant. If the digest window feels wrong (too long, too short, users confused), remove the digest field and redeploy. Sends go back to direct delivery immediately. Any buffered payloads in the current window will flush on the next flushDigests() call — they won't be lost.

Render function patterns

The render() function is where you decide how a batch of events reads as a single notification. Here are patterns for common use cases:

PatternOutput exampleWhen to use
Latest actor + count"Ava and 4 others commented"Comments, reactions, follows — user wants to see who and how many
Actor list (truncated)"Rey, Sam, and 3 others liked your post"Social actions — names add social proof
Summary count only"12 new updates in Project X"High-volume feeds where individual actors don't matter
Most recent event"Latest: deployment succeeded (+ 5 more)"System/CI events — latest status matters most
lib/notifications/render-examples.ts
// Latest actor + count
render: ({ payloads, count }) => ({
  actorName: payloads[payloads.length - 1]!.actorName,
  summary: count === 1
    ? `${payloads[0]!.actorName} commented`
    : `${payloads[payloads.length - 1]!.actorName} and ${count - 1} others commented`,
  postUrl: payloads[0]!.postUrl,
  count,
})

// Actor list (truncated to 2 names)
render: ({ payloads, count }) => {
  const unique = [...new Set(payloads.map(p => p.actorName))]
  const names = unique.slice(0, 2)
  const rest = unique.length - names.length

  return {
    actorList: rest > 0
      ? `${names.join(", ")}, and ${rest} others`
      : names.join(" and "),
    action: "liked your post",
    postUrl: payloads[0]!.postUrl,
    count,
  }
}

// Summary count only
render: ({ payloads, count }) => ({
  summary: `${count} new updates`,
  projectName: payloads[0]!.projectName,
  latestUrl: payloads[payloads.length - 1]!.url,
  count,
})

// Most recent event + tail count
render: ({ payloads, count }) => ({
  latest: payloads[payloads.length - 1]!.message,
  count,
  summary: count === 1
    ? payloads[0]!.message
    : `${payloads[payloads.length - 1]!.message} (+ ${count - 1} more)`,
})
Deduplicate actors in render. If the same user likes a post 5 times in a window, you probably don't want "Rey, Rey, Rey, and 2 others." Use new Set() on actor names before building the display string.

Render pitfalls

MistakeSymptomFix
Returning fields not in payload schemaValidation error on flushThe rendered output must match your payload definition exactly
Ignoring single-item case"Rey and 0 others"Branch on count === 1 for singular phrasing
Expensive computation in renderSlow flush under loadKeep render pure and fast — no DB calls, no fetch, no async
Not handling empty payloadsRuntime crash on payloads[0]!Won't happen — engine only calls render when count >= 1

Monitoring digests in production

Digests introduce a delay between send() and delivery. Monitor these signals to catch misconfigurations before users notice:

SignalHealthyProblemAction
Items per flush2–20 payloads per digestConsistently 1 item — window is too short, adding latency without reducing noiseIncrease windowMs or remove digest for this notification
Flush latencyWithin seconds of flushAtDigests flushing minutes late — cron or timer isn't runningCheck your flush mechanism (cron interval, setTimeoutQueue health)
Buffer growthBuffers clear on each flush cycleBuffer rows accumulating without flushingVerify flushDigests() is being called — check for crashes in the worker
Render errorsZerorender() throwing — digest stuck, never deliversCheck logs for validation errors — returned shape must match payload schema
lib/notifykit.ts
createNotifyKit({
  // ...
  on: {
    "notification.created": ({ notification }) => {
      if (notification.digestedCount && notification.digestedCount > 0) {
        metrics.histogram("notifykit.digest.items_per_flush", notification.digestedCount, {
          notification: notification.notificationId,
        })
      }
    },
  },
})
scripts/flush-cron.ts
const flushed = await notify.flushDigests()
metrics.gauge("notifykit.digest.last_flush_count", flushed.length)
Digest + rate limit interaction recap. Rate limits run before digest buffering. If 100 sends hit a notification with rateLimit: { max: 50 } and a 5-minute digest, only 50 enter the buffer — the other 50 are permanently dropped. Monitor both the rate-limited count and the digest count to understand the full picture.

Debugging digest issues

Digests add a time-delayed step to the send pipeline. When they misbehave, the symptoms are confusing — sends appear to succeed (no error) but notifications never arrive. Use this table to trace the problem:

SymptomLikely causeHow to verifyFix
Notification never arrives (no email, no inbox item from digest)flushDigests() isn't runningCheck your cron logs or setTimeoutQueue health. Call flushDigests() manually — if items arrive, the timer is broken.Add flushDigests() to a cron (every 30s) or switch to setTimeoutQueue() which auto-flushes.
Digest delivers but with wrong content (e.g. count is 0, missing fields)render() function has a bugLog the payloads array passed to render. Check that your return shape matches the payload schema exactly.Fix the render logic. Common issues: accessing payloads[0] without !, not handling the single-item case, returning fields not in the schema.
Some sends digest, others deliver immediatelyDigest key function returns different values for events you expected to groupLog the key return value for each send. Different keys = separate digest buckets, each flushing independently.Ensure your key function returns the same string for events that should batch. Don't include actor IDs or timestamps in the key.
Digest flushes but user gets a validation error (notification not delivered)render() returns a shape that doesn't match the payload schemaCheck error logs around flush time. The engine validates the rendered payload the same way it validates a normal send().Ensure render() returns every required field from your payload schema with the correct type.
Digest always contains only 1 itemWindow is too short, or events are too spread outLog count in your render function. If consistently 1, the window expires before a second event arrives.Increase windowMs (try 10–15 min), or remove digest entirely if events don't actually burst.
send() returns digested: true but inbox item appears immediatelyThis is correct — inbox delivers per-send, only push channels (email/SMS) are digestedCheck result.inboxItems vs result.deliveries. Inbox items write immediately; email delivery waits for flush.No fix needed — this is by design. Inbox is pull-based (user opens it when ready), so batching adds no value there.
1
Check if sends enter the buffer

After send(), check result.digested === true. If false, the notification doesn't have a digest config — or dedup/rate-limit intercepted it earlier.

2
Check if flush runs

Call flushDigests() manually from a script. If it returns results, your automatic flush mechanism is broken. If empty, nothing has expired yet — wait for windowMs to pass.

3
Check render output

If flush runs but delivery fails, the issue is in render(). Add temporary logging to see what payloads and count look like, then verify your return matches the schema.

4
Check downstream pipeline

The flushed send goes through preferences, quiet hours, and delivery — same as a normal send. Use explain() with the rendered payload to check if another stage blocks it.

scripts/diagnose-digest.ts
import { notify } from "@/lib/notifykit"

async function diagnoseDigest() {
  const result = await notify.send({
    recipientId: "test_user",
    notificationId: "new_comments",
    payload: { actorName: "Debug", postTitle: "Test", postUrl: "/test", count: 1 },
  })
  console.log("Digested:", result.digested)

  const flushed = await notify.flushDigests()
  console.log("Flushed count:", flushed.length)
  if (flushed.length > 0) {
    console.log("Deliveries:", flushed[0].deliveries.length)
  } else {
    console.log("Nothing to flush — window hasn't expired yet")
  }
}

diagnoseDigest()
Most digest bugs are flush bugs. If send() returns digested: true, the buffering works. The problem is almost always that nothing is calling flushDigests() after the window expires. With setTimeoutQueue() this is automatic; with any other queue, you need a cron or worker loop.