Designing a Notification System
Notifications are a shared budget spent against one person's attention, and the default is ambient: it lands in an inbox, it raises a count, and the user finds it when they look. An interrupt — a toast, a push, an email in the middle of the day — has to be argued for, per event, in writing. Design the system before the surface: a toast component built without a priority tier and a dedupe key becomes a firehose the moment a second feature starts emitting. Boundary with motion: how a toast enters, stacks, and swipes away is motion's (see motion/references/patterns.md); how often it fires, what tier it carries, and whether it coalesces is this skill's. Boundary with ui-states: an error rendered where the data would have been is ui-states; an error thrown at the user somewhere else is this skill. Push-permission timing belongs to onboarding.
Find the emitters before writing any component. Grep for what already sends: a toast library (sonner, react-hot-toast, @radix-ui/react-toast), a push service worker and Notification calls, a mail sender (resend, postmark, sendgrid, nodemailer), an existing notifications table, a job queue, and any per-user preference or timezone column. Most products already have three uncoordinated emitters and no shared tier, and the real work is routing them through one emit path — not adding a fourth. Match the project's existing template and styling systems for both in-app surfaces and email.
Quick Reference
| Topic |
Where |
| Once an event's tier is known and you need to route it to concrete surfaces — which channels fire, what batches, what quiet hours do, what the user can turn off |
priority-matrix.md |
| Before writing or reviewing any transactional email markup — client constraints, table layout, inline CSS, dark mode, images, width, preheader, unsubscribe headers |
html-email.md |
Decision Framework: interrupt or ambient?
An event may interrupt only if both are true:
- The user has to act on it within this session, and
- not seeing it now causes a loss they cannot recover later.
One yes is not enough. "Their teammate replied" fails (2). "Your export finished" fails (1) — that is a badge. "Your payment failed and service stops in 24 hours" passes both. Everything failing the test is ambient: inbox row, badge count, or an entry in a digest.
Then place the surface by tier:
| Tier |
In-app |
Push / email |
Persists? |
critical |
Blocking or persistent banner |
Both, bypasses quiet hours |
Until resolved, not until seen |
actionable |
Toast with an action, plus inbox row |
Push if the user opted in; batched email |
Inbox row until acted on |
informational |
Inbox row, badge count |
Digest only |
Until read |
ambient |
Count only |
Never |
Auto-expires |
Core Principles
Assign the tier at emit and store it on the event. Routing, batching, quiet hours, digesting, and the user's preference screen all key off one field; deciding tier inside the toast component means every channel re-derives it differently and they drift within a quarter. Tier is one of critical | actionable | informational | ambient. Exception: a user preference may demote a tier; nothing but the system may promote anything to critical.
One event produces one notification across all channels. Fan out from a single emit carrying a shared dedupe_key, so the same comment cannot arrive as a toast and a push and an email and an inbox row. If the user was looking at the screen when it happened, the other channels are suppressed. Exception: critical intentionally duplicates across channels, because the cost of a miss exceeds the cost of the annoyance.
Coalesce on (actor, verb, object) inside a window; render counts, never repeats. Five comments on one document is one row reading "Sam and 4 others commented on Roadmap", not five rows — an inbox that repeats is an inbox people stop opening. Newest activity refreshes the row's timestamp and un-reads it. Exception: critical never coalesces; two failed payments are two problems.
Compute toast duration from word count at 200–250 wpm; never ship a flat value for every message. A nine-word warning and a two-word confirmation cannot share a timer — one is unreadable and the other overstays. Pause the timer while the toast is hovered or focused, and while the tab is hidden, so a message emitted in a background tab is still there on return. Exception: a burst of identical confirmations collapses into one slot at the shortest computed duration rather than stacking.
A toast carrying an action or an error does not auto-dismiss. An Undo that expires while the user is still reading it is a trap, and an error that vanishes leaves someone with no way to find out what happened. Give both an explicit close. Exception: an undo whose window is genuinely time-bounded by the backend may auto-dismiss when that window closes — and it must show the window, not hide it.
A badge counts only things the user can act on, and it clears on view, not on visit. The count is unread AND actionable, nothing else — a number padded with marketing announcements teaches people the number is a lie, after which it is decoration. Entering the surface is not reading; clear per item as each is seen. Exception: an unbounded or expensive-to-count set gets a dot with no number rather than a wrong number.
Read and dismissed state is per-user, server-side, and syncs across devices. Store read_at and dismissed_at on the (user, notification) row; dismissing on a laptop and finding it unread on a phone is the most common trust failure in this surface. Exception: transient toasts, which are the one notification class that legitimately dies with the tab — anything that must survive a reload was never a toast.
Never notify a user about their own foreground action. The UI changing is the confirmation; a Saved toast on top of a visibly saved document is noise, and it is how a product ends up with three toasts per interaction. Exception: actions whose result is invisible or arrives later (a background export, an async invite), and any action with an undo window.
Quiet hours defer; they never drop. Compute from the user's stored timezone, not the server's, and release the queue at the boundary as a batch rather than a burst. A dropped notification is a bug the user reports as data loss. Exception: critical bypasses quiet hours, and that is the entire reason the tier exists.
Every channel is independently controllable, and bulk mail is unsubscribable in one click. Ship List-Unsubscribe with List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) on bulk sends, and keep transactional and marketing on separate streams and separate preference toggles — unsubscribing from a newsletter must never stop a password-reset email. Exception: none. A transactional message that cannot be turned off must genuinely be transactional.
Smell / Fix
| Smell |
Fix |
| Toast, push, email and inbox row for one comment |
One emit, one dedupe key, suppress the rest |
duration: 3000 on every toast |
Derive from word count at 200–250 wpm |
| Undo toast that auto-dismisses |
Actions and errors never auto-dismiss |
| Badge that counts announcements |
Count only actionable items; clear per item on view |
Read state in localStorage |
Per-user, server-side, synced |
| Five rows for five comments on one doc |
Coalesce on (actor, verb, object) |
| "Saved" toast over a visibly saved document |
Delete it; the UI change is the confirmation |
| Quiet hours implemented as a drop |
Defer and release at the boundary |
| Tier decided inside the toast component |
Assign at emit, store on the event |
| Quiet hours computed in server time |
Use the user's stored timezone |
| One "notifications" on/off switch |
Per-channel and per-tier controls |
| Inline error thrown as a toast |
Render it where the data was — ui-states |
Output format
Spec a notification system as one row per event type, before any component is written:
| Event |
Tier |
Channels |
Dedupe key |
Coalesce on / window |
Dismiss |
Quiet hours |
Channels lists only what actually fires after suppression. Dismiss is one of auto (computed), explicit, or on resolve. Quiet hours is defer or bypass — and bypass requires the tier to be critical.
Checklist
1---2name: notifications3description: Use when designing notification surfaces: toasts, badges, push, inbox, frequency and batching, priority, quiet hours, and transactional email layout.4---567# Designing a Notification System89Notifications are a shared budget spent against one person's attention, and the default is **ambient**: it lands in an inbox, it raises a count, and the user finds it when they look. An interrupt — a toast, a push, an email in the middle of the day — has to be argued for, per event, in writing. Design the system before the surface: a toast component built without a priority tier and a dedupe key becomes a firehose the moment a second feature starts emitting. **Boundary with `motion`:** how a toast enters, stacks, and swipes away is `motion`'s (see `motion/references/patterns.md`); how often it fires, what tier it carries, and whether it coalesces is this skill's. **Boundary with `ui-states`:** an error rendered where the data would have been is `ui-states`; an error thrown at the user somewhere else is this skill. Push-permission *timing* belongs to `onboarding`.1011**Find the emitters before writing any component.** Grep for what already sends: a toast library (`sonner`, `react-hot-toast`, `@radix-ui/react-toast`), a push service worker and `Notification` calls, a mail sender (`resend`, `postmark`, `sendgrid`, `nodemailer`), an existing `notifications` table, a job queue, and any per-user preference or timezone column. Most products already have three uncoordinated emitters and no shared tier, and the real work is routing them through one emit path — not adding a fourth. Match the project's existing template and styling systems for both in-app surfaces and email.1213## Quick Reference1415| Topic | Where |16| --- | --- |17| Once an event's tier is known and you need to route it to concrete surfaces — which channels fire, what batches, what quiet hours do, what the user can turn off | [priority-matrix.md](references/priority-matrix.md) |18| Before writing or reviewing any transactional email markup — client constraints, table layout, inline CSS, dark mode, images, width, preheader, unsubscribe headers | [html-email.md](references/html-email.md) |1920## Decision Framework: interrupt or ambient?2122An event may interrupt only if **both** are true:23241. The user has to act on it **within this session**, and252. not seeing it now causes a loss they cannot recover later.2627One yes is not enough. "Their teammate replied" fails (2). "Your export finished" fails (1) — that is a badge. "Your payment failed and service stops in 24 hours" passes both. Everything failing the test is ambient: inbox row, badge count, or an entry in a digest.2829Then place the surface by tier:3031| Tier | In-app | Push / email | Persists? |32| --- | --- | --- | --- |33| `critical` | Blocking or persistent banner | Both, bypasses quiet hours | Until resolved, not until seen |34| `actionable` | Toast with an action, plus inbox row | Push if the user opted in; batched email | Inbox row until acted on |35| `informational` | Inbox row, badge count | Digest only | Until read |36| `ambient` | Count only | Never | Auto-expires |3738## Core Principles39401. **Assign the tier at emit and store it on the event.** Routing, batching, quiet hours, digesting, and the user's preference screen all key off one field; deciding tier inside the toast component means every channel re-derives it differently and they drift within a quarter. Tier is one of `critical | actionable | informational | ambient`. *Exception:* a user preference may **demote** a tier; nothing but the system may promote anything to `critical`.41422. **One event produces one notification across all channels.** Fan out from a single emit carrying a shared `dedupe_key`, so the same comment cannot arrive as a toast *and* a push *and* an email *and* an inbox row. If the user was looking at the screen when it happened, the other channels are suppressed. *Exception:* `critical` intentionally duplicates across channels, because the cost of a miss exceeds the cost of the annoyance.43443. **Coalesce on `(actor, verb, object)` inside a window; render counts, never repeats.** Five comments on one document is one row reading "Sam and 4 others commented on Roadmap", not five rows — an inbox that repeats is an inbox people stop opening. Newest activity refreshes the row's timestamp and un-reads it. *Exception:* `critical` never coalesces; two failed payments are two problems.45464. **Compute toast duration from word count at `200–250 wpm`; never ship a flat value for every message.** A nine-word warning and a two-word confirmation cannot share a timer — one is unreadable and the other overstays. Pause the timer while the toast is hovered or focused, and while the tab is hidden, so a message emitted in a background tab is still there on return. *Exception:* a burst of identical confirmations collapses into one slot at the shortest computed duration rather than stacking.47485. **A toast carrying an action or an error does not auto-dismiss.** An `Undo` that expires while the user is still reading it is a trap, and an error that vanishes leaves someone with no way to find out what happened. Give both an explicit close. *Exception:* an undo whose window is genuinely time-bounded by the backend may auto-dismiss when that window closes — and it must show the window, not hide it.49506. **A badge counts only things the user can act on, and it clears on view, not on visit.** The count is `unread AND actionable`, nothing else — a number padded with marketing announcements teaches people the number is a lie, after which it is decoration. Entering the surface is not reading; clear per item as each is seen. *Exception:* an unbounded or expensive-to-count set gets a dot with no number rather than a wrong number.51527. **Read and dismissed state is per-user, server-side, and syncs across devices.** Store `read_at` and `dismissed_at` on the `(user, notification)` row; dismissing on a laptop and finding it unread on a phone is the most common trust failure in this surface. *Exception:* transient toasts, which are the one notification class that legitimately dies with the tab — anything that must survive a reload was never a toast.53548. **Never notify a user about their own foreground action.** The UI changing *is* the confirmation; a `Saved` toast on top of a visibly saved document is noise, and it is how a product ends up with three toasts per interaction. *Exception:* actions whose result is invisible or arrives later (a background export, an async invite), and any action with an undo window.55569. **Quiet hours defer; they never drop.** Compute from the user's stored timezone, not the server's, and release the queue at the boundary as a batch rather than a burst. A dropped notification is a bug the user reports as data loss. *Exception:* `critical` bypasses quiet hours, and that is the entire reason the tier exists.575810. **Every channel is independently controllable, and bulk mail is unsubscribable in one click.** Ship `List-Unsubscribe` with `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) on bulk sends, and keep transactional and marketing on separate streams and separate preference toggles — unsubscribing from a newsletter must never stop a password-reset email. *Exception:* none. A transactional message that cannot be turned off must genuinely be transactional.5960## Smell / Fix6162| Smell | Fix |63| --- | --- |64| Toast, push, email and inbox row for one comment | One emit, one dedupe key, suppress the rest |65| `duration: 3000` on every toast | Derive from word count at `200–250 wpm` |66| Undo toast that auto-dismisses | Actions and errors never auto-dismiss |67| Badge that counts announcements | Count only actionable items; clear per item on view |68| Read state in `localStorage` | Per-user, server-side, synced |69| Five rows for five comments on one doc | Coalesce on `(actor, verb, object)` |70| "Saved" toast over a visibly saved document | Delete it; the UI change is the confirmation |71| Quiet hours implemented as a drop | Defer and release at the boundary |72| Tier decided inside the toast component | Assign at emit, store on the event |73| Quiet hours computed in server time | Use the user's stored timezone |74| One "notifications" on/off switch | Per-channel and per-tier controls |75| Inline error thrown as a toast | Render it where the data was — `ui-states` |7677## Output format7879Spec a notification system as one row per event type, before any component is written:8081| Event | Tier | Channels | Dedupe key | Coalesce on / window | Dismiss | Quiet hours |82| --- | --- | --- | --- | --- | --- | --- |8384`Channels` lists only what actually fires after suppression. `Dismiss` is one of `auto (computed)`, `explicit`, or `on resolve`. `Quiet hours` is `defer` or `bypass` — and `bypass` requires the tier to be `critical`.8586## Checklist8788- [ ] Every event has a tier assigned at emit and stored on the record89- [ ] Interrupt-vs-ambient test applied per event, both conditions required90- [ ] One event, one notification; dedupe key present; in-session suppression works91- [ ] Coalescing key and window defined for every repeatable event92- [ ] Toast durations computed from word count; timer pauses on hover, focus, and hidden tab93- [ ] Toasts with actions or errors do not auto-dismiss94- [ ] Badge counts actionable items only and clears per item on view95- [ ] Read/dismissed state per-user and server-side96- [ ] Quiet hours defer, computed in the user's timezone; only `critical` bypasses97- [ ] Transactional and marketing streams separated; `List-Unsubscribe-Post` on bulk mail98- [ ] Toast motion handed to `motion`; permission timing handed to `onboarding`