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.
| Digest | Rate limit | |
|---|---|---|
| What it does | Batches sends into one delivery | Drops sends that exceed a threshold |
| Excess sends are... | Buffered and included in the batch | Gone — permanently dropped |
| Use when | Multiple 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 digest | With 5-min digest | |
|---|---|---|
| 10:00 | Email: "Rey commented on Launch Plan" | (buffering...) |
| 10:01 | Email: "Sam commented on Launch Plan" | |
| 10:02 | Email: "Ava commented on Launch Plan" | |
| 10:03 | Email: "Rey commented on Launch Plan" | |
| 10:04 | Email: "Sam commented on Launch Plan" | |
| 10:05 | — | Email: "5 new comments on Launch Plan" |
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).
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
Creates a buffer entry with flushAt = now + windowMs. No notification delivered yet.
Sends with the same digest key append their payload to the buffer. Still no delivery.
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:
| Input | Example value |
|---|---|
payloads | [{ actorName: "Rey", postTitle: "Launch", count: 1 }, { actorName: "Sam", postTitle: "Launch", count: 1 }, { actorName: "Ava", postTitle: "Launch", count: 1 }] |
count | 3 |
| Your return → | { actorName: "Ava", postTitle: "Launch", count: 3 } |
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.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 broad | Key too narrow | Key just right |
|---|---|---|
| All comments → one digest | Each comment → its own digest (no batching) | Comments on the same post → one digest per post |
| "8 things happened" — user can't tell what or where | 8 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 type | Key returns | User sees |
|---|---|---|
| Comments on a post | payload.postId | "5 new comments on Launch Plan" |
| Reactions on any content | `${payload.targetType}:${payload.targetId}` | "12 reactions on your comment" |
| Task updates in a project | payload.projectId | "7 task updates in Backend Refactor" |
| New followers | "followers" (constant) | "4 new followers this week" |
| Deploy status per service | payload.serviceName | "3 deploys to api-gateway (latest: succeeded)" |
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 }) => ({ /* ... */ }),
}`${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 type | How flush happens | Action needed |
|---|---|---|
setTimeoutQueue() | Internal timer per bucket | None — automatic |
inlineQueue() | N/A (no background work) | Call flushDigests() on a cron |
| Custom (BullMQ, SQS) | Your worker | Call 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.
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
| Scope | Counts |
|---|---|
"recipient" (default) | Sends to the same recipient for this notification |
"global" | All sends for this notification across all recipients |
Evaluation order
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:
Is the recipient under the threshold? No → permanently dropped. Yes → continue.
Payload appended to the recipient's bucket. Returns digested: true. No delivery yet.
render() combines all buffered payloads into one. A normal send executes with the combined payload.
One email/notification reaches the user — regardless of how many events passed the rate limit.
| With 100 sends in 1 hour | Rate limit: max 50 | No rate limit |
|---|---|---|
| Sends that pass | 50 (other 50 permanently dropped) | 100 |
| Enter digest buffer | 50 payloads | 100 payloads |
| Digests delivered | 1 email: "50 new activities" | 1 email: "100 new activities" |
| User disruption | 1 notification | 1 notification |
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:
| Window | Good for | User experience |
|---|---|---|
| 1–2 min | Chat-style apps, live collaboration | Near-instant but collapses rapid-fire bursts (3 messages → 1 email) |
| 5–10 min | Comments, reactions, task updates | Feels timely without being noisy. Best default for most apps. |
| 30–60 min | Social feeds, activity digests | Batches a burst of activity into one summary. Good for "5 people liked your post." |
| 4–24 hours | Daily summaries, weekly roundups | Single comprehensive notification. Pair with a cron-triggered flush. |
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:
| Approach | How | Best for |
|---|---|---|
| Short window in dev | Override windowMs to 5 seconds in development | Manual testing in the browser — see the digest arrive quickly |
| Manual flush | Call notify.flushDigests() directly after sends | Automated tests — deterministic, no timers involved |
| Explain / dry run | Check result.digested === true on the send result | Verifying the send entered the buffer without waiting for flush |
notification({
id: "new_comments",
// ...
digest: {
windowMs: process.env.NODE_ENV === "test" ? 0 : 5 * 60_000,
key: ({ payload }) => payload.postUrl,
render: ({ payloads, count }) => ({ /* ... */ }),
},
})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)
})
})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.
Your render() needs to return the full payload shape. If your current payload lacks a count or summary field, add it as optional first.
Add digest to the notification definition. From this moment, new sends buffer instead of delivering immediately.
If you use inlineQueue() or a custom queue, add a flushDigests() call to your cron. setTimeoutQueue() handles this automatically.
Watch items-per-flush in logs. If most digests contain only 1 item, the window is too short for your traffic pattern.
// 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}}" }),
],
})// 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,
}),
},
})| Concern | What happens | Action |
|---|---|---|
| Sends already in flight | They don't have count in their payload | Make count optional in the schema. Handle missing values in render() with a default. |
| Inbox items | Inbox still delivers per-send (not batched) | Expected — inbox is pull-based. Only push channels (email/SMS) digest. |
| Existing preferences | User opt-outs still apply to the digested send | No change needed — preferences resolve on the flushed send, not on buffering. |
| Rate limits | Rate limit counts individual sends (before buffering) | You may want to increase the rate limit now that digests reduce actual delivery volume. |
| User experience during deploy | First digest window after deploy may contain only 1 item | Normal — the window starts counting from the first send after deploy. Subsequent windows batch properly. |
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.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:
| Pattern | Output example | When 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 |
// 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)`,
})new Set() on actor names before building the display string.Render pitfalls
| Mistake | Symptom | Fix |
|---|---|---|
| Returning fields not in payload schema | Validation error on flush | The 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 render | Slow flush under load | Keep render pure and fast — no DB calls, no fetch, no async |
Not handling empty payloads | Runtime 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:
| Signal | Healthy | Problem | Action |
|---|---|---|---|
| Items per flush | 2–20 payloads per digest | Consistently 1 item — window is too short, adding latency without reducing noise | Increase windowMs or remove digest for this notification |
| Flush latency | Within seconds of flushAt | Digests flushing minutes late — cron or timer isn't running | Check your flush mechanism (cron interval, setTimeoutQueue health) |
| Buffer growth | Buffers clear on each flush cycle | Buffer rows accumulating without flushing | Verify flushDigests() is being called — check for crashes in the worker |
| Render errors | Zero | render() throwing — digest stuck, never delivers | Check logs for validation errors — returned shape must match payload schema |
createNotifyKit({
// ...
on: {
"notification.created": ({ notification }) => {
if (notification.digestedCount && notification.digestedCount > 0) {
metrics.histogram("notifykit.digest.items_per_flush", notification.digestedCount, {
notification: notification.notificationId,
})
}
},
},
})const flushed = await notify.flushDigests()
metrics.gauge("notifykit.digest.last_flush_count", flushed.length)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:
| Symptom | Likely cause | How to verify | Fix |
|---|---|---|---|
| Notification never arrives (no email, no inbox item from digest) | flushDigests() isn't running | Check 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 bug | Log 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 immediately | Digest key function returns different values for events you expected to group | Log 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 schema | Check 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 item | Window is too short, or events are too spread out | Log 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 immediately | This is correct — inbox delivers per-send, only push channels (email/SMS) are digested | Check 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. |
After send(), check result.digested === true. If false, the notification doesn't have a digest config — or dedup/rate-limit intercepted it earlier.
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.
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.
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.
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()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.