Design a notification center
The bell icon with an unread badge and a panel of notifications, as in GitHub, LinkedIn, Facebook and Jira. Drawing a list is easy; the hard parts are a badge that stays correct across tabs and devices, live delivery that survives reconnects, "seen" versus "read", grouped items like "Ana and 4 others liked your post", mark all as read without a race, and push notifications that don't duplicate the in-app ones.
The question
Design the notification center for our social and work app. There's a bell in the top bar with a red badge for unread notifications. Clicking it opens a panel with recent notifications: likes, comments, mentions, invites, follows. There's also a full "All notifications" page.
Walk me through how the badge count stays right, how new notifications arrive live, how the panel loads and renders different types, how mark as read works, and how this relates to browser push notifications when the tab is closed.
Real-life examples: GitHub's notification inbox, LinkedIn's Notifications tab, Facebook's bell menu, Jira's notification drawer, Slack's Activity tab
Requirements: what to ask before drawing anything
A notification is a short record that something happened that concerns you: someone liked your post, mentioned you, invited you to a team, or a build you started failed. A notification center is the place in the app where these records collect: a bell button in the top bar with a badge (the small red number), a panel that opens under the bell with the most recent items, and usually a full page with the whole history.
It looks like a small feature, a list in a popover. The interviewer picks it because the list is the easy part. The real questions are about state that lives in many places at once: the server, every open tab, the phone app, and the operating system's notification tray. The number on the badge must agree across all of them, and it must stay right while new items arrive and old ones get read.
Questions that change the design
| Question | Answer we assume | What it changes |
|---|---|---|
| What kinds of notifications? | About 15 types: like, comment, reply, mention, follow, team invite, review request, build failed, and so on. New types ship every month. | A type-to-renderer registry, so a new type doesn't touch the panel code. |
| How many does a user get? | Median 10 a day; heavy users 500 a day; a viral post can bring 2,000 likes in an hour. | Aggregation ("Ana and 4 others"), a capped badge (99+), and pagination. |
| Must they arrive live? | Yes, within about 2 seconds while a tab is open. | A push channel (WebSocket or SSE) plus a fallback. |
| Multiple tabs and devices? | Yes. People keep 3 to 5 tabs open and also use the phone app. | The server is the source of truth for counts; tabs share state locally. |
| Does opening the panel clear the badge? | Yes. The badge counts unseen; items stay highlighted until read (clicked). | Two states per item: seen and read. |
| Can you act inside the panel? | Yes: Accept an invite, Approve a review, quick Reply to a comment. | Inline actions with their own loading and error states. |
| Push when the tab is closed? | Yes, opt-in browser push, plus email for some types. | A service worker and a preferences matrix (type by channel). |
| How long is history kept? | 90 days on the All notifications page. | Cursor pagination over a long list; old items are archived server-side. |
Functional requirements
- A bell button in the top bar on every page, with a badge showing the unseen count, capped as
99+. - Clicking the bell opens a panel with the 20 newest notifications, grouped as Today, This week, and Earlier, with more loading as you scroll.
- Filters: All, Unread, and Mentions.
- Each type renders its own way (avatar, text, preview, thumbnail) and links to its target: the post, the comment, the pull request.
- Some types have inline actions: Accept or Decline an invite, Reply to a comment.
- Mark one as read (by clicking it or with a button), mark one as unread, and Mark all as read.
- New notifications appear live at the top of the open panel and bump the badge.
- Browser push when no tab is open, and a preferences page to choose channels per type.
Non-functional requirements
- Cheap on every page: the bell ships on every page, so its code must stay under about 3 KB compressed and its count request must cost the server almost nothing.
- Fast panel: the first 20 items visible within 300 ms of the click on a warm connection, with a skeleton in the meantime.
- Consistent: the badge never shows a count that a click on the bell would contradict for more than a few seconds, across tabs and devices.
- Live: new items within 2 seconds while connected; after a disconnect, nothing is lost and nothing shows twice.
- Accessible: the bell's name includes the count; the panel works with keyboard only; new arrivals are announced without flooding a screen reader.
- Localized: relative times ("5 minutes ago") and plural strings ("4 others") in the user's language.
Out of scope, and why
We leave out how the backend decides who gets a notification (the fan-out service), email templates, and the native mobile app's code. We also leave out toasts: the short "Saved" messages are a different component with its own guide (toast-notifications). We do touch the boundary, because a new notification can show a toast, and that must not repeat what a push already said.
Check your understanding · Exercise 1
Which answers from the interviewer change the frontend design the most? Select all that apply.
What makes this hard
Five traits of the problem drive every decision that follows. Name them early in the interview: it shows you see past the list.
- One number, many writers. The badge count changes when a new event arrives, when you open the panel in tab A, when you read an item on your phone, and when the server merges two likes into one item. Four sources can change it, and the bell in tab B must agree with all of them. Whoever owns the number decides how hard the rest is.
- Always on, rarely opened. The bell is on every page, all day. The panel is opened maybe 5 times a day. So the always-on part (badge, live connection) must be tiny and cheap, and the rarely used part (panel, renderers, actions) should load only when asked.
- Live, with holes. Laptops sleep, trains go into tunnels, servers deploy. A live connection drops many times a day. Every drop is a window in which events were sent and not received. The client must find out what it missed without loading everything again.
- Many shapes. A like, a mention, a review request and a failed build look different and offer different actions. The list must render 15 types today and 30 next year without becoming one huge switch statement.
- State in two worlds. Inside the tab, the app shows notifications in its own panel. Outside the tab, the operating system shows browser push notifications. The two must not say the same thing twice, and a click on either must land in the same place.
Here is what that means in numbers for a heavy user. 500 notifications a day is about one every 2 minutes across 16 waking hours. If each one costs a full refetch of the 20-item panel (about 15 KB of JSON), that's 7.5 MB a day per user for data the client mostly already has.
If each one moves focus or speaks in a screen reader, the user can't work. And with 3 tabs open, a naive design sends 3 times the requests and plays 3 sounds.
Think first
Why can't the client simply count the unread items it has in memory and show that number on the badge?
Because the client only holds the first page or two. A user with 340 unread notifications has 20 in memory, so the client would say 20.
And even if it had them all, it can't see changes made elsewhere: reading on the phone, or the server merging 5 likes into one item. The count must come from the server, and the client adjusts it locally only as a short-lived guess that the next server value overwrites.
The notification itself: seen, read, and grouped
Before any UI, decide what one notification is. Get this wrong and every later chapter fights it. A notification has a stable id, a type (like, mention, invite), one or more actors (who did it), a target (the thing it's about: a post, a comment, a pull request), a creation time, and its state for this user.
Seen is not read
The naive model has one boolean, read. Open the panel, and either everything becomes read (so the highlight on items you never looked at disappears), or nothing does (so the badge stays at 7 after you clearly looked). Both feel wrong, so many products track two states instead.
Hosted notification services such as Stream's feeds API keep is_seen and is_read for each item, and Facebook's older Graph API reported an unseen_count apart from each notification's unread flag. A common result in social apps: the badge clears the moment you open the list, but each item keeps its tinted background until you click it.
- Seen: the item has been shown to you in the panel. Opening the panel marks everything up to now as seen. The badge counts unseen items.
- Read: you acted on the item, usually by clicking through to its target. The tint in the list and the Unread filter use read.
Read implies seen: you can't click what you haven't seen. Mark as unread brings back the tint but not the badge, because you have still seen it.
GitHub is a useful contrast: its bell shows only a dot, no number, meaning "you have unread notifications", and there is no separate seen state. Both are valid; ask which the product wants. We assume the two-state model because it is the harder one and the interviewer said the badge clears on open.
Seen state is best stored as a single watermark per user, not a flag per item: seenUpTo = 2026-10-20T09:14:03Z. Opening the panel sends one tiny write that moves the watermark to the newest item's time, and the unseen count is "items created after the watermark". That is one row update, no matter if 3 or 3,000 items were unseen. Read state is per item, because people read items one by one and out of order.
Aggregation: "Ana and 4 others liked your post"
A post that gets 40 likes in an hour should not produce 40 rows. It should produce one row that says "Ana, Ben and 38 others liked your post", with up to 3 avatars. The question is where the grouping happens.
Decision Where should raw events become grouped notifications?
What we pick: Group on the server at write time. The badge, the list, push, and email all count and show the same items, and ids stay stable so read state works. The client still owns how a group is worded, because wording is language-specific: the server sends actors and a count, and the client turns them into a sentence.
Write-time grouping has three rules to spell out. First, the group key: type plus target, like like:post_123. Second, the window: a new like joins the group only while the group is younger than 24 hours, so a like next week starts a fresh item.
Third, what a join does to state: the group's updatedAt moves to now, it jumps to the top of the list, and it becomes unread and unseen again. If it was already unseen, the badge doesn't change (still one item); if it was seen, the badge goes up by one.
Check your understanding · Exercise 2
A user opens the panel, sees a "Ben liked your post" item, closes the panel without clicking it. Then Cara likes the same post. What should happen? Select all that apply.
The unread badge: one number, many writers
The badge is the most visible pixel of the feature and the easiest to get wrong. The naive version keeps a counter in the tab: add 1 on each new event, subtract 1 on each read, set 0 when the panel opens. It drifts within an hour.
A dropped event leaves it 1 too low forever. A read on the phone never reaches it. A like that joins a group which is still unseen (chapter 3) is a new event but not a new unseen item, so the tab adds 1 when the truth didn't change. Arithmetic on a number someone else owns always drifts.
Decision Where does the number on the badge come from?
What we pick: The server sends the absolute count. Replacing beats adding: a tab that missed 3 events shows the right number as soon as the 4th arrives. Polling stays only as the fallback when the live connection can't be opened (chapter 5).
Making the count cheap
The count is "items for this user updated after their seen watermark". With an index on (user_id, updated_at) that is an index range scan, but for a user with 40,000 unseen items it still reads 40,000 index entries.
The trick: the badge never shows more than 99+, so the server never needs to count past 100. It counts with a limit of 100 and returns { unseenCount: 100, capped: true }, which the badge shows as 99+. Many systems also cache the count per user and update it on write, so a read costs one key lookup.
export function badgeText(count: number, locale: string): string | null {
if (count <= 0) return null; // no badge at all, not a "0"
if (count > 99) return "99+"; // the server caps at 100
return new Intl.NumberFormat(locale).format(count);
}
Keeping tabs in agreement
Each tab gets the server's events, so each converges. But there is a gap: you open the panel in tab A, A sets its badge to 0 optimistically and sends the seen write, and tab B keeps showing 7 until the server's event comes back, maybe 300 ms later, or much later if B's socket is reconnecting.
BroadcastChannel closes that gap. It is a browser API that lets same-origin tabs, iframes and workers post messages to each other, supported in every current browser (Safari was last, in version 15.4 in 2022). Tab A posts { count: 0, seq } and tab B applies it at once.
Ordering matters, because messages from the server and from other tabs interleave. Every count carries the server's seq (a number that only grows per user). A tab keeps the highest seq it has applied and ignores anything older. An optimistic local value reuses the current seq, so the next real server value, which always carries a higher seq, replaces it.
Across devices nothing special is needed: the phone writes to the server, and the server sends the new count to every open connection of that user.
const channel = new BroadcastChannel("notif");
let state = { count: 0, seq: 0 };
export function applyCount(count: number, seq: number, from: "server" | "tab") {
if (seq < state.seq) return; // stale
state = { count, seq };
render();
if (from === "server") channel.postMessage({ count, seq });
}
channel.onmessage = (e) => applyCount(e.data.count, e.data.seq, "tab");
export function onPanelOpened() {
applyCount(0, state.seq, "tab"); // optimistic, same seq
channel.postMessage({ count: 0, seq: state.seq });
api.markSeen({ upTo: newestUpdatedAt() }); // reply carries a higher seq
}
Should only one tab hold the live connection and share it with the others? It saves connections (5 tabs, 1 socket), and the Web Locks API (navigator.locks) makes electing that leader tab simple. But it adds failure modes: the browser can freeze or discard the leader tab in the background, and the others go quiet until a new leader takes over. Start with one connection per tab plus BroadcastChannel for instant local changes; move to a leader tab when connection count becomes a server cost problem.
Check your understanding · Exercise 3
Tab B's socket was down for 2 minutes. During that time the user read 4 items on the phone. Tab B reconnects and receives one new "mention" event carrying unseenCount: 3. Tab B showed 6 before. What should B show? Select all that apply.
Live delivery, and what happens after a disconnect
A new notification should appear at the top of an open panel and bump the badge within about 2 seconds. That needs the server to push. The traffic is almost all one way, server to client: the client's writes (mark as read, accept) are ordinary HTTP requests.
Decision How do new notifications reach an open tab?
What we pick: If the app already keeps a WebSocket open, as Slack-style apps do for chat, send notifications on it as one more event type: one connection, one heartbeat. If not, SSE is the better fit for a one-way feed, because resume via Last-Event-ID comes for free. Keep short polling as the fallback for networks that break long-lived connections. The server-sent-events and websockets lessons cover both protocols.
Events that can be applied twice
Every event carries a per-user seq, the new absolute unseenCount, and enough data to update the store without another request. Each one is written so that applying it twice gives the same result as applying it once: create is an upsert by id, seen and read are watermarks or flags set to a value, never toggled. That property makes reconnects simple, because overlap between the replay and the live stream does no harm.
type NotifEvent =
| { kind: "created"; seq: number; unseenCount: number; item: Notification }
| { kind: "updated"; seq: number; unseenCount: number; item: Notification } // group join
| { kind: "read"; seq: number; unseenCount: number; ids: string[]; read: boolean }
| { kind: "seen"; seq: number; unseenCount: number; upTo: string }
| { kind: "readAll"; seq: number; unseenCount: number; upTo: string }
| { kind: "reset"; seq: number }; // cursor too old: refetch
function apply(ev: NotifEvent) {
if (ev.seq <= lastSeq) return; // already applied
lastSeq = ev.seq;
if (ev.kind === "reset") return refetchFirstPage();
store.dispatch(ev); // upsert, set flags, move watermarks
applyCount(ev.unseenCount, ev.seq, "server");
}
Reconnect with a cursor, then fill the gap
The client remembers the last seq it applied. When the connection comes back, it sends that number. SSE does it by itself through the Last-Event-ID header, but only on the browser's own automatic reconnects; a new EventSource that the app creates starts without it, so pass the seq in the URL then. On a WebSocket the client puts it in its first frame.
The server keeps a replay buffer of recent events per user (say the last 24 hours or 1,000 events). If the cursor is inside the buffer, the server replays everything after it and then continues live. If it is older, say a laptop that slept all weekend, the server sends reset, and the client throws away its pages, fetches the first page and the count, and starts fresh. That is the same resume-or-reset rule the slack guide uses for messages.
seq. Inside the replay window it gets exactly what it missed; outside it, a reset tells it to start fresh rather than show a silent hole.Reconnect timing needs care too. Wait with exponential backoff plus random jitter (1 s, 2 s, 4 s, up to 30 s, each with up to 50 percent randomness) so that a server restart doesn't bring 1 million clients back in the same second. Reconnect immediately on the online event and when the tab becomes visible again. After 3 failed attempts, or if the stream closes within seconds every time (a sign of a proxy that buffers or cuts long responses), switch to polling.
The polling fallback asks one cheap question: GET /notifications/count every 60 seconds while the tab is visible, and not at all while it's hidden. With a validator, a poll with no news costs a 304 Not Modified with an empty body: ETag with If-None-Match, or Last-Modified with If-Modified-Since. Every few minutes, the client tries the live stream again.
GitHub's notifications API is a public example of this design. It is built for polling with Last-Modified, answers 304 when nothing changed (without counting against the rate limit), and sends an X-Poll-Interval header with the number of seconds to wait, which it raises when its servers are busy. Honor such a header: it lets the server slow every client down at once.
Check your understanding · Exercise 4
A tab reconnects with cursor seq 812. The server replays 813 to 820. While replaying, event 821 also arrives live, and the replay batch also ends with 821. What does the client need to get right? Select all that apply.
The panel: load late, page by cursor, render by type
The panel is most of the code and most of the pixels, and the user opens it a handful of times a day. So the first rule is: don't ship it with the page. The bell button and badge are in the main bundle (a few KB). The panel, its list, its 15 renderers and its action handlers sit in a separate chunk loaded with a dynamic import() the first time it's needed (the code-splitting lesson covers the mechanics).
Loading on click adds the chunk's download to the first open. Two tricks hide it. First, start loading the chunk on intent: pointerenter or focus on the bell gives 100 to 300 ms of head start before the click.
Second, on click, start the data request and the chunk download at the same time, not one after the other. On a warm connection both finish in about 150 ms, inside the 300 ms budget; until then, a tiny shell that ships with the bell shows 5 skeleton rows.
seen reply only confirms it.Pages by cursor, not by offset
The list changes at the top while you read it. With offset pages (?offset=20&limit=20), 3 new arrivals shift everything down, so page 2 starts with 3 items you already have on page 1. Group joins are worse: an item jumps from position 30 to position 1, every item above it shifts by one, and the next offset page repeats an item.
A cursor names a position in the sort order instead: ?before=2026-10-20T08:00:00Z_n_8812 means "items that sort after this one". It's stable while the top moves. The pagination lesson compares the two in depth.
The sort key is updatedAt, not createdAt, so a group that just got a new like rises to the top. Because two items can share a timestamp, the cursor includes the id as a tie-breaker. An item that moves to the top while you're on page 3 is simply upserted by id: the store has one copy, and the list order is recomputed from the sort key.
Time sections and filters
The sections Today, This week, and Earlier are computed on the client from updatedAt in the user's time zone, because "today" depends on where the user is. They're just headings inserted while rendering the sorted list.
Filters are different: Unread and Mentions are server queries (?filter=unread), since the client never has all items. Each filter has its own list of ids and cursor, but all lists point into one normalized store of items by id. So reading an item in the Mentions view also clears its tint in the All view (the normalization lesson explains why one copy per entity matters).
Fifteen types without one giant switch
Decision How does the panel render 15 different notification types, with more coming?
What we pick: Use the registry with a generic fallback. The fallback fields cost a few bytes per item and make old clients safe when the server ships a new type, which happens monthly. Rich renderers stay with the teams that own each type.
const registry: Record<string, React.LazyExoticComponent<Renderer>> = {
like: lazy(() => import("./renderers/Like")),
mention: lazy(() => import("./renderers/Mention")),
invite: lazy(() => import("./renderers/Invite")),
review_request: lazy(() => import("./renderers/ReviewRequest")),
};
export function NotificationRow({ n }: { n: Notification }) {
const Rich = registry[n.type];
const fallback = <GenericRow text={n.text} url={n.url} image={n.image} />;
if (!Rich) return fallback;
return (
<ErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<Rich n={n} />
</Suspense>
</ErrorBoundary>
);
}
Deep links and inline actions
Each item links to its target: the comment inside the post, the line in the pull request. Make it a real a element with an href, so middle-click and "open in new tab" work, and mark the item as read on click without waiting: the request goes out with fetch(url, { keepalive: true }), which lets it finish even though the page navigates away.
Inline actions (Accept, Decline, Reply) run inside the panel. A link can't contain buttons, so the row is a list item that holds the link and, next to it, the action buttons.
Each action has its own small state: idle, pending (button disabled with a spinner), done (the buttons are replaced by "You joined Design team"), or failed (an inline error with Retry). The panel stays open, and when the buttons disappear, focus moves to the row's link so it doesn't fall back to the page body. Accepting also marks the item as read, since the user clearly handled it.
Last, live arrivals while the panel is open. If the user is at the top of the list, prepend the new item. If they have scrolled down to read, don't move what's under their eyes: show a "2 new" button at the top of the panel that scrolls up and inserts them. The same rule appears in the news-feed and slack guides.
Items shown while the panel is open count as seen too: the client moves the seen watermark again, at most once every few seconds, so the badge stays at 0 while you watch.
Check your understanding · Exercise 5
The Mentions filter is showing 20 items. A teammate's comment mentions the user and also joins an existing "comment" group. Which statements are true? Select all that apply.
Mark as read, and mark all as read without a race
Marking one item as read is a textbook optimistic update: change the store first so the tint disappears at once, send POST /notifications/n_8812/read, and roll back with a small error if it fails (the optimistic-updates lesson covers the pattern). Reading does not change the badge in our model, because the badge counts unseen items and anything you can click has already been seen. Mark as unread is the same request with read: false.
Mark all as read is where designs break. The user has 340 unread items; the client holds 40 of them.
Decision What does the client send for Mark all as read?
What we pick: Send a watermark. GitHub's REST API works the same way: PUT /notifications takes a last_read_at time, and anything updated after it is not marked as read. The request stays a few bytes for any number of items, and the race disappears because the user's intent is pinned to what they saw.
Reconciling with items that arrive mid-request
On the client, the optimistic step uses the same rule as the server: set read: true on every item in the store whose updatedAt is at or before upTo. A live created event that arrives during the request has a later updatedAt, so it stays unread with its tint. A group join that lands mid-request also moves updatedAt past the watermark, so the group comes back unread: correct, because it has news.
On the server, an item counts as read if it was marked read after its last update, or if its updatedAt is at or before the user's readAllUpTo, unless it was marked unread later. Mark all becomes one row update, not 340.
async function markAllRead() {
const upTo = newestVisibleUpdatedAt();
const changed = store.items
.filter((n) => !n.read && n.updatedAt <= upTo)
.map((n) => n.id);
store.setRead(changed, true); // tint disappears now
try {
const res = await api.post("/notifications/read-all", { upTo });
apply({ kind: "readAll", upTo, ...res }); // seq + unseenCount from server
} catch {
// undo only our change; items read elsewhere meanwhile stay read
store.setRead(changed.filter((id) => !store.readElsewhere(id)), false);
showInlineError("Couldn't mark all as read.", { retry: markAllRead });
}
}
Check your understanding · Exercise 6
The newest item on screen was updated at 09:14:03.1, and the user clicks Mark all as read at 09:14:05, so upTo is 09:14:03.1. A mention is created at 09:14:05.4, and the server processes the request at 09:14:05.6. What happens? Select all that apply.
Push when the tab is closed, without saying it twice
Everything so far needs an open tab. When no tab is open, the browser can still show a system notification through Web Push: the page registers a service worker (a script the browser can run without the page), subscribes with registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }), and sends the resulting subscription to our server.
Later the server sends a message to the browser vendor's push service, which wakes our service worker, which calls self.registration.showNotification(...). The push-notifications lesson walks through keys, payload encryption and subscription expiry; here we focus on how push and the in-app center fit together.
Support is broad now: Chrome, Edge and Firefox on desktop and Android, Safari 16 and later on macOS Ventura and newer, and on iPhone and iPad since iOS 16.4, but only for web apps the user has added to the Home Screen. The userVisibleOnly: true flag is a promise that every push shows a visible notification; Chrome and Edge reject a subscription without it, so silent "just update the badge" pushes aren't an option for us.
One event, one alert
The duplicate problem: Ana mentions you while your tab is open. The live stream adds the item to the panel and maybe a toast, and the push arrives too, so the operating system shows the same mention in the corner of the screen. Worse with a phone: your desktop tab and your phone both buzz.
Decision Who decides between an in-app alert and a push?
What we pick: Route on the server by presence, with a short delay. Slack offers the same idea as a user setting: send notifications to mobile devices only when you're inactive on desktop, optionally after a delay. In the service worker, set tag to the item's group key so a later push for the same group replaces the earlier system notification instead of stacking a second one; add renotify: true only if the replacement should alert the user again.
tag makes a later push for the same group replace the earlier one in the tray.self.addEventListener("push", (event) => {
const n = event.data.json();
event.waitUntil(
self.registration.showNotification(n.title, {
body: n.text, icon: n.image, tag: n.groupKey, data: { url: n.url, id: n.id },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const { url } = event.notification.data;
event.waitUntil((async () => {
const tabs = await clients.matchAll({ type: "window", includeUncontrolled: true });
const tab = tabs.find((c) => new URL(c.url).origin === self.location.origin);
if (tab) { tab.postMessage({ kind: "open", url }); return tab.focus(); } // app routes itself
return clients.openWindow(url);
})());
});
The landing page marks the item as read, exactly as if the user had clicked it in the panel, so the badge in every tab updates through the normal event path. When an item is read in the app, the server can also send a small push so the service worker closes the matching system notification with registration.getNotifications({ tag }); that avoids stale alerts sitting in the tray.
Asking for permission
Never call Notification.requestPermission() on page load. Users deny prompts they didn't ask for, and a denial is permanent for that site until they dig into browser settings. Browsers push back too: Firefox (since version 72) and Safari only show the prompt in response to a user gesture such as a click, and Chrome switches sites with low acceptance rates to a quieter prompt that most people never notice.
- Wait for a moment where push clearly helps: the user just asked a question in a thread, or requested a review.
- Show our own small, dismissible in-app card: "Get notified when someone replies, even with this tab closed?" with Turn on and Not now.
- Only on Turn on, call
Notification.requestPermission()from that click. - If the result is
denied, stop asking. On the preferences page, show the state and a short note on how to re-enable it in browser settings. - After Not now, wait at least a couple of weeks before showing the card again.
Check your understanding · Exercise 7
Which push designs are sound? Select all that apply.
Preferences: which types, on which channels
A notification center without controls gets muted at the operating system level, and then nothing reaches the user. Give people a preferences matrix: notification types (or groups of types) as rows, channels as columns. Our channels are in-app, push, and email. Each cell is on or off; email also has a frequency.
| Type group | In-app | Push | |
|---|---|---|---|
| Mentions and replies | on, locked | on | instant |
| Review requests | on, locked | on | instant |
| Likes and reactions | on | off | daily digest |
| New followers | on | off | weekly digest |
| Security alerts | on, locked | on | instant, locked |
Store only what the user changed. The server holds defaults per type, and each user has a small map of overrides, like { "like.push": true }. That keeps storage tiny for the many users who never open the page, and it lets the product change a default for everyone who hasn't overridden it.
The server applies the matrix when it routes a notification; the client only edits it. Never filter on the client: a muted type that is still sent to the client still costs bandwidth and still shows in other clients.
Two more controls live outside the page, right where the annoyance happens. Mute this thread sits in an item's overflow menu: it stores a mute on the target (post_123), so further replies there stop notifying, while mentions of you by name still come through. Turn off notifications like this jumps to the matching row in the matrix. Both are one click from the item, and both show an Undo toast, since the user may click by mistake.
A digest is the email answer to volume: instead of 40 emails for 40 likes, one email at 9:00 local time listing the day's activity. It needs the user's time zone, which the client sends from Intl.DateTimeFormat().resolvedOptions().timeZone when the user signs in. Items read in-app before the digest goes out are left out of it.
For the matrix itself, save each toggle as it changes, with an optimistic update and a polite "Saved" status message, instead of a Save button at the bottom that people forget to press. Each checkbox needs a full accessible name, built from both headers: "Likes and reactions, push". A screen reader user tabbing through 15 checkboxes named only "push" can't tell them apart.
Check your understanding · Exercise 8
Which choices for preferences are sound? Select all that apply.
Architecture, data model, and API: a tiny always-on core and a lazy panel
Now put the pieces together, split by how often code runs. The core runs on every page all day: the bell, the badge store, the live connection, and tab sync. It must be small and must not depend on React rendering, because events arrive whether or not the panel is mounted. The panel loads on first use and holds everything else.
| Piece | Loads | Responsibility |
|---|---|---|
BellButton | With the shell | Renders the icon, the badge text, and the accessible name; toggles the panel; prefetches the panel chunk on hover or focus. |
badgeStore | With the shell | Holds { count, seq }; applies server and tab values, newest seq wins. A plain module with a subscribe function, read by React with useSyncExternalStore. |
liveConnection | With the shell | Opens SSE (or joins the app's socket), resumes from the last seq, backs off with jitter, falls back to polling, and routes events to the stores. |
tabSync | With the shell | A BroadcastChannel that shares counts and local writes between tabs. |
notificationStore | With the shell, empty | Normalized items by id plus one list per filter (ids and a cursor). Live events upsert into it even before the panel exists, so opening it later shows them. |
NotificationPanel | Lazy chunk | Popover, filters, time sections, list with infinite scroll, Mark all as read. |
| Renderer registry | Lazy, per type | One component per type, plus the generic fallback. |
| Action handlers | Lazy, per type | Accept, Decline, Reply: requests with their own pending and error states. |
| Service worker | Registered after opt-in | Receives push, shows system notifications, handles clicks. |
| Preferences page | Its own route | Edits the matrix and the push subscription. |
The stores live outside React for two reasons. First, the live connection must apply an event the moment it arrives, even if no component that cares is mounted; a store in React state would lose events while the panel is closed.
Second, several unrelated parts read the same data: the bell, the panel, the page title ("(3) Inbox" style counters), and the All notifications page. A module-level store with subscribe works for all of them, and useSyncExternalStore lets React read it without tearing (two components showing different values from the same render).
The All notifications page is the same panel components in a full-page layout, with the same store and lists. Opening it from the panel's "See all" link reuses the items already loaded, so it renders instantly and continues paging from the same cursor.
How data moves
Reads come in through two doors: HTTP pages (the first page when the panel opens, more pages on scroll, the count on boot) and live events. Both land in the same stores through the same apply path, which is why reconnects and overlaps are safe. Writes go out as HTTP requests (seen, read, read-all, actions), with an optimistic change applied first; the server's reply and its echo event carry the new seq and count, and the tab shares them on the BroadcastChannel.
apply, with its seq check, which is why a replay overlapping the live stream is harmless. Writes leave from the UI and come back as a reply and an event with a new seq.Check your understanding · Exercise 9
Which pieces belong in the always-loaded core rather than the lazy panel chunk? Select all that apply.
Client data model and API
Each kind of client data lives where its lifetime fits. Items, lists and the count stay in memory only: notifications are cheap to refetch and can be private, so we don't persist them to localStorage. The selected filter is component state in the panel, but on the All notifications page it goes into the URL (/notifications?filter=mentions) so it survives reload and can be shared. Preferences are fetched on the preferences page only.
type Actor = { id: string; name: string; avatarUrl: string };
type Notification = {
id: string; // "n_8812", stable across group joins
type: string; // "like" | "mention" | ... (open-ended)
groupKey: string; // "like:post_123"
actors: Actor[]; // first 3, newest first
actorCount: number; // 40 means "Ana, Ben and 38 others"
target: { kind: string; id: string; title?: string };
url: string; // deep link
text: string; image?: string; // fallback rendering
createdAt: string; updatedAt: string; // ISO 8601, UTC
read: boolean;
actions?: { id: string; label: string; style: "primary" | "secondary" }[];
};
type NotifState = {
byId: Record<string, Notification>;
lists: Record<"all" | "unread" | "mentions", { ids: string[]; cursor: string | null }>;
seenUpTo: string; // for the "new since you looked" divider
lastSeq: number;
};
Notice what isn't there: no seen flag per item. Seen is the user's watermark, so the client computes it as updatedAt > seenUpTo when it needs to draw a "New" divider. And the server never sends display sentences like "Ana and 38 others liked your post" for the rich renderers, only actors and counts, because the sentence depends on the user's language (the i18n section below). The text field is only the fallback for unknown types, already localized by the server.
GET /notifications?filter=all&before=<cursor>&limit=20
-> { items: Notification[], nextCursor: "2026-10-20T08:00:00Z_n_8812" | null }
GET /notifications/count (ETag, 304 when unchanged)
-> { unseenCount: 7, capped: false, seq: 905 }
POST /notifications/seen { upTo: "2026-10-20T09:14:03.120Z" }
POST /notifications/read-all { upTo: "2026-10-20T09:14:03.120Z" }
POST /notifications/n_8812/read { read: true }
POST /notifications/n_8812/actions/accept
-> { seq: 906, unseenCount: 0, item?: Notification }
GET /notifications/stream (SSE; Last-Event-ID: 905)
-> id: 906
data: { kind: "created", seq: 906, unseenCount: 1, item: {...} }
error -> 4xx/5xx { error: { code: "invite_expired", message: "..." } }
Error codes matter for inline actions. An invite that expired returns invite_expired, and the row replaces its buttons with "This invite has expired" instead of a generic "Something went wrong". A 409 on accept (already accepted on another device) is treated as success: the goal state is reached.
Check your understanding · Exercise 10
Why does every write response include seq and unseenCount? Select all that apply.
Performance, accessibility, language, and safety
Performance: cheap on every page
The bell is on every page, so it must cost close to nothing. The count is rendered into the page's first HTML by the server (or included in the app's boot data), so there is no extra request and the badge is right in the first paint.
The badge sits absolutely positioned over the icon, so it appearing or growing from 9 to 99+ never shifts layout (no Cumulative Layout Shift). The live connection opens after the page's main content has loaded, so it doesn't compete with the Largest Contentful Paint image for bandwidth.
The core is a budget of about 3 KB compressed: badge store, connection, tab sync, and the button. The panel chunk with the list, popover and fallback renderer is about 25 KB, and each rich renderer 1 to 5 KB more, loaded only when that type appears. In hidden tabs, keep applying events to the stores but skip rendering work; when the tab becomes visible, render once.
On the All notifications page, a history of 90 days can reach thousands of rows; past a few hundred, render only the visible window (the virtualized-list guide covers it). Avatars are 32 CSS pixels, so request a 64-pixel image for 2x screens, never the 400-pixel profile photo.
Accessibility
The bell is a button whose accessible name includes the count: "Notifications, 3 new" ("new" because our badge counts unseen items; a badge that counts unread items would say "3 unread"). The red number alone is a visual cue that a screen reader would read as a bare "3" or skip. With no unseen items the name is just "Notifications".
The button carries aria-expanded to say whether the panel is open, and aria-controls pointing at the panel. Changing the name doesn't make a screen reader speak it; new arrivals get their own announcement, below.
The panel holds links, buttons, filter tabs, and sometimes a reply box, so it is not a menu: the menu role expects only menu items and arrow-key navigation, and it would hide the links' meaning. Make it a non-modal dialog (role="dialog" with an aria-labelledby pointing at its "Notifications" heading), or a plain disclosure region.
On open, move focus to the heading (give it tabindex="-1" so it can take focus). Escape closes the panel and returns focus to the bell; a click outside closes it and leaves focus where the user clicked, as in the dropdown-select guide. Tab moves through each item's link and then its action buttons, and is not trapped, because the dialog is non-modal. The aria-patterns lesson covers the popover mechanics.
Announcing new arrivals is where good intentions hurt. A live region (an element whose text changes a screen reader reads out without moving focus) that speaks every event interrupts a screen reader user 500 times a day. Use one visually hidden role="status" region, which is polite: it waits until the reader is idle. Render it empty with the page, as the toast-notifications guide does, because a region inserted together with its text is often not read.
Then batch: at most one message every 10 seconds, only while the tab is visible, and as a summary like "2 new notifications". Replayed events after a reconnect are not announced. Mentions, which are addressed to the user, can be worded more specifically: "Ana mentioned you". The screen-readers lesson explains how polite and assertive regions queue speech.
Times and plurals in every language
Relative times come from Intl.RelativeTimeFormat: new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-1, "day") gives "yesterday" in English and "ayer" in Spanish. Refresh them once a minute, only while the panel is open. Wrap each one in a time element with the exact datetime, and show the full date in a tooltip.
Aggregate sentences need two more tools. Intl.ListFormat joins names: "Ana, Ben, and Cara" in US English, "Ana, Ben y Cara" in Spanish. Plural rules (Intl.PluralRules, which ICU message formats use) pick the right form of "others": English has 2 plural forms, Polish 3 for whole numbers, and Arabic 6. The locale-formatting lesson covers the Intl APIs.
// en.json
// "others": "{count, plural, one {# other} other {# others}}"
// "like": "{actors} liked your post"
function likeSentence(n: Notification, t: Translate, locale: string) {
const parts = n.actors.slice(0, 2).map((a) => a.name);
const others = n.actorCount - parts.length;
if (others > 0) parts.push(t("others", { count: others })); // "38 others"
const actors = new Intl.ListFormat(locale, { type: "conjunction" }).format(parts);
return t("like", { actors }); // "Ana, Ben, and 38 others liked your post"
}
Safety
Notifications are full of other people's text: names, comment previews, post titles. Render them as text, never as HTML, so a display name like an image tag with an onerror handler stays harmless characters. Check every deep link before using it: only relative paths on our own origin, never a javascript: URL or another domain, or a notification becomes a phishing link inside our trusted UI.
Check your understanding · Exercise 11
Which accessibility choices are right for the notification center? Select all that apply.
Running it in the interview
Forty-five minutes go fast. Spend them where the signal is: the count, the live stream with its gaps, and mark all as read. A strong plan:
| Minutes | What you do | What you draw |
|---|---|---|
| 0 to 5 | Ask the scope questions: volume, seen versus read, multi-tab and devices, inline actions, push. | Nothing yet; write the numbers down. |
| 5 to 10 | Name what makes it hard. Define the notification: id, type, actors, target, times, read; seen as a watermark; grouping on the server. | The item state machine. |
| 10 to 20 | The badge: server-owned absolute count with seq, capped at 99+, BroadcastChannel between tabs. Live delivery: SSE or the existing socket, resume by seq, reset when too old, polling fallback. | The architecture with core and lazy panel; the reconnect sequence. |
| 20 to 30 | The panel: lazy chunk, cursor pages by updatedAt, filters on the server, registry with fallback, inline actions. | The panel wireframe and the open sequence. |
| 30 to 38 | Mark one and mark all, with the watermark and the race. | The mark-all timeline. |
| 38 to 45 | Push routing and permission UX, preferences, accessibility, i18n; answer follow-ups. | The push sequence if time allows. |
Say the core idea out loud early, because every later answer follows from it: "The server owns the count and every event carries the absolute value with a sequence number. The client never does arithmetic on it, except an optimistic zero that the next server value replaces."
Follow-ups to expect
Think first
"A celebrity posts and gets 200,000 likes in an hour. What breaks?"
Write-time grouping keeps it to one item, but that item updates 55 times a second. Sending each update to the celebrity's 4 open tabs is wasteful and makes the row flicker.
The server should coalesce updates to a group: at most one updated event per item every few seconds, carrying the latest actor count. The badge doesn't change at all after the first one, since it's still one unseen item. Push is coalesced the same way, using the same tag.
Think first
"The user opens the laptop after a week. What do they see in the first second?"
The server-rendered count from the page load is correct immediately. The stream connects with a seq from a week ago, which is outside the replay window, so the server sends reset.
The client drops its old lists, and the panel, when opened, fetches the first page fresh. Nothing is announced to the screen reader: these are not new arrivals from the user's point of view, just history.
Think first
"How would you test that the badge never drifts?"
Write the store as a pure function from events to state and property-test it: generate random event sequences with duplicates, reordering and gaps, and check the final count equals the last applied server value.
In end-to-end tests, open two browser contexts for the same user, read on one, and assert the other's bell name changes to the new count within 2 seconds. Add a test that blocks the stream for 30 seconds and checks the polling fallback takes over.
Check your understanding · Exercise 12
You have 5 minutes left and haven't covered push. Which summary is strongest? Select all that apply.
Summary
- Seen and read are different states: the badge counts unseen items and clears when the panel opens; the tint shows unread items and clears on click. Store seen as one watermark per user.
- Group events on the server at write time ("Ana and 38 others") so the badge, the list, push and email count the same items with stable ids; build the sentence on the client in the user's language.
- The server owns the count. Every reply and event carries the absolute
unseenCountand aseq; tabs replace, never add, and share values overBroadcastChannel, newest seq wins. - Live delivery rides the app's socket or SSE; reconnect resumes from the last seq, falls back to a full reset when the cursor is too old, and to polling when the stream can't stay open. Make every event safe to apply twice.
- Ship a tiny core (bell, badge store, connection) on every page, and load the panel, its per-type renderers and its actions only on first open, starting on hover.
- Page by cursor on
updatedAtwith the id as a tie-breaker; offsets break when items arrive and groups move to the top. - Mark all as read sends a watermark time, not ids and not "everything", so items that arrive during the request stay unread.
- Push is for closed tabs: route by presence on the server with a short delay, tag by group key, and ask permission only after a user's click on your own card.
- Name the bell with its count, make the panel a non-modal dialog, and announce new arrivals politely and in batches.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Push API Subscriptions, the push event, and userVisibleOnly.
- MDN: ServiceWorkerRegistration.showNotification() Options such as tag, data, and actions.
- MDN: Using server-sent events Event ids, Last-Event-ID, and the per-host connection limit.
- MDN: BroadcastChannel Messaging between same-origin tabs.
- GitHub REST API: Notifications Polling with Last-Modified and X-Poll-Interval, and a last_read_at watermark for mark as read.
- web.dev: Permission UX When and how to ask for notification permission.
- WAI-ARIA Authoring Practices: Dialog (Modal) pattern Focus handling for dialogs; the panel uses the non-modal variant of these rules.
- MDN: Intl.RelativeTimeFormat "5 minutes ago" and "yesterday" in every locale.
- MDN: Intl.ListFormat Locale-aware lists like "Ana, Ben, and 38 others".
- Stream: Notification feeds A hosted notification API with separate seen and read states and counts.
- MDN: Intl.PluralRules Plural categories per language.
Concepts behind it
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.