cf.completefrontend
My progress
SYSTEM DESIGN · #47

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

QuestionAnswer we assumeWhat 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.

Wireframe of the notification center. A top bar holds the bell button with a red badge showing 3. Under the bell, an open panel has the heading Notifications and a Mark all as read link, three filter chips (All, selected, Unread, and Mentions), a Today section with a tinted unread row, Ana and 4 others liked your post, and an invite row from Ben with Accept and Decline buttons, then an Earlier section with a plain read row, Cara mentioned you. Notes on the left explain that the badge counts unseen items capped at 99+, that the tint marks unread items, and that Accept runs inside the panel.app, panel opentop bar on every pagebell3NotificationsMark all as readAllUnreadMentionsTodayAna and 4 others liked your postBen invited you to Design[ Accept ] [ Decline ]EarlierCara mentioned youbadge: unseen countcapped at 99+name: 3 newtint: unread rowssections by dayAccept runs in place
Two different states show here: the badge counts unseen items and clears when the panel opens, while the tint on a row stays until that row is read. Accept and Decline run in place, without leaving the panel.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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.

State machine of one notification for one user. Three states left to right: unseen, counted in the badge and tinted; seen, tinted but not in the badge; and read, a plain row. Opening the panel moves unseen to seen. A click or mark as read moves seen to read. Mark as unread moves read back to seen. A new actor joining the group moves seen back to unseen. A click on a push notification moves unseen straight to read. Notes: seen is one watermark per user, read is a flag per item; the badge counts unseen, the tint shows unread.seen: one watermark per userread: a flag per itembadge counts unseentint shows unreadunseenbadge + tintseentint onlyreadplain rowopenclicknewsunreadpush click or deep link: straight to read
Read implies seen, so mark as unread returns to seen, not to unseen: the badge doesn't come back. Only news on the item, like another like joining the group, makes it unseen again.

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?

Client groups the raw events

The server returns one row per event.

The client groups rows with the same type and target into one item before rendering.

Pros

  • Simple server.
  • The UI can change grouping rules without a backend deploy.

Cons

  • A page of 20 raw events might collapse to 2 items: pagination breaks.
  • The badge (counted on the server) and the list (grouped on the client) disagree: 40 versus 1.
  • Every client (web, iOS, Android, email digest) must reimplement the same rules.

Server groups at read time

Raw events are stored as they come.

The list endpoint groups them with a query before paging.

Pros

  • One set of rules for every client.
  • Rules can change without rewriting stored data.

Cons

  • Grouping on every read is costly for heavy users.
  • Item ids are unstable: the group's id depends on the query, so marking one as read is awkward.

Server groups at write time

When a like arrives, the server looks for an open group with the key like:post_123 and adds the actor to it, or creates a new item.

The item stores the first few actors and a total count.

Pros

  • Stable ids, cheap reads, pages of real items.
  • The badge counts the same items the list shows.
  • All clients render the same thing.

Cons

  • Rules are baked into stored data; changing them only affects new events.
  • An updated group must move to the top and reach clients as an update, not a new item.

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.

Aggregation lab. 0 raw events become 0 items with grouping by type and post. The badge shows none. Plural category for 0 others: en: other, pl: many, ar: zero.raw events: 0items: 0, badge noneplural category for 0 others: en: other, pl: many, ar: zerogroup key = type + post, window 24 h; 0 rows saved
No events yet. Add a few likes to post 1.

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?

Tab keeps a running counter

Fetch the count once on load.

Add and subtract as events and clicks happen.

Pros

  • No extra requests.
  • Instant updates.

Cons

  • Drifts on every missed event, group join, or change on another device.
  • Each tab drifts differently, so two tabs show two numbers.

Poll a count endpoint

Every 30 to 60 seconds, GET /notifications/count.

Show whatever it says.

Pros

  • Always converges to the truth.
  • Works with no live connection at all.

Cons

  • Up to a minute stale.
  • 1 million open tabs polling every 30 seconds is 33,000 requests per second for a number that usually didn't change.

Server sends the absolute count with every change

The bootstrap response and every live event carry unseenCount and a sequence number seq.

The tab replaces its number with the server's; it never adds or subtracts, except an optimistic 0 when the panel opens.

Pros

  • Self-healing: one received event fixes any earlier drift.
  • Changes from other devices arrive as events with the new count.
  • No polling while the connection is up.

Cons

  • The server must compute the count on each change.
  • Needs a live connection, so a fallback is still needed.

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.

ts
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);
}
Badge text: the server's number, capped for display.

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.

ts
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
}
One badge store per tab: newest seq wins, from the server or another tab.

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.

Mark all sends
Badge lab. Tab A shows 3 at seq 3; tab B shows 3 at seq 3 and is online; the phone shows 3 at seq 3. The server is at seq 3 with 3 unseen items, seen up to t0. Tab A's newest rows: Cara replied, unseen; Ben mentioned, unseen; Ana liked, unseen.Tab A3onlineseq 3Cara replied newBen mentioned newAna liked newTab B3onlineseq 3listens on the channelPhone3onlineseq 3server events onlyserver: seq 3, unseen 3seen up to t0, clock t3no request in flightrow tags: new = unseenevents logged: 0
Three unseen items. Every device shows 3. Try opening the panel in tab A.

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?

Short polling

Every 30 seconds, ask "anything since cursor X?".

Pros

  • Plain HTTP; works through every proxy.
  • Easy to cache with ETag and 304 Not Modified.

Cons

  • Average delay is half the interval: 15 seconds.
  • Most requests return nothing, at a large request volume.

Server-Sent Events (SSE)

new EventSource("/notifications/stream") keeps one HTTP response open; the server writes events as text lines.

Each event has an id: line; on reconnect the browser sends it back in the Last-Event-ID header.

Pros

  • Server to client is all we need.
  • Reconnect and resume cursor are built into the browser.
  • Ordinary HTTP, so auth cookies and HTTP/2 work as usual.

Cons

  • One way only.
  • Over HTTP/1.1 each open stream uses one of the browser's 6 connections per host, so many tabs can starve other requests; HTTP/2 or HTTP/3 avoids this.

WebSocket

One two-way socket; the server sends JSON frames.

The app implements its own heartbeat, reconnect, and resume cursor.

Pros

  • Two-way and low overhead.
  • If the app already has one (for chat or presence), notifications can ride on it for free.

Cons

  • More to build: reconnect, backoff, and resume are all yours.
  • A separate socket just for notifications is heavier than SSE for one-way data.

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.

ts
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");
}
The stream's event types. Every one is safe to apply more than once.

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.

Sequence diagram of a reconnect between the tab, the stream server, and the replay buffer. The tab connects with Last-Event-ID 812. The stream asks the buffer for events after 812, and the buffer returns 813 to 820. The stream replays 813 to 820 to the tab with the latest count, then continues with live event 821. If the cursor were older than the buffer, the stream would send reset instead, and the tab would refetch the first page and the count.TabStream serverReplay bufferconnect, Last-Event-ID: 812events after 812?813 to 820replay 813 to 820, count 4live 821, count 5or, if 812 is too old: reset
The tab sends only a number, its last applied 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.

State machine of the live connection. Connecting sends the last seq and moves to live when the stream opens. Live applies events; a drop moves it to waiting. Waiting backs off 1, 2, 4 up to 30 seconds with jitter, then tries connecting again, or right away when the browser goes online or the tab becomes visible. A failed connect also moves to waiting. After 3 failures, waiting moves to polling, which asks for the count every 60 seconds and retries the stream every few minutes, moving back to live when it works.connectingsend last seqliveapply eventswaitingjitter, max 30 spollingcount every 60 sopeneddropfailstimer, online3 failuresretry works
Every path ends back at live. Polling is a mode the connection falls into and climbs out of, not a separate feature, so the rest of the app never knows which one is running.

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.

Sequence diagram of the first panel open, between the user, the bell, the panel chunk, and the API. The user hovers the bell, and the bell starts importing the panel chunk. The user clicks. The bell requests page 1 from the API. The chunk arrives, about 25 KB, then 20 items arrive. The panel renders and moves focus to its heading. Then the bell posts seen up to the newest item, and the API answers with seq 906 and count 0.UserBellPanel chunkAPIhoverimport() startsclickGET page 1chunk, 25 KB20 itemsrender, focusPOST seen upToseq 906, count 0
Hover gives the chunk a head start, and the click starts the data request without waiting for the code, so the two downloads overlap. The badge goes to 0 at once; the 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?

One component with a switch on type

switch (n.type) with a case per type inside the list item.

Pros

  • Easy to start; everything in one place.

Cons

  • Every new type edits the shared file; teams collide.
  • All renderers load even if this user never gets review requests.
  • An unknown type from a newer server crashes or renders nothing.

Server-driven layout

The server sends ready-made display data: text with bold ranges, an image, a link, a list of actions.

Pros

  • New types need no client deploy.
  • Old clients render new types.

Cons

  • Rich, interactive types (an inline reply box, a diff preview) don't fit a generic layout.
  • Action behaviour still needs client code.

Renderer registry with a generic fallback

A map from type to a lazily loaded component; each team registers its own.

Every notification also carries generic fields (text, url, image), used by a default renderer for unknown types.

Pros

  • Rich renderers where they help, and no crash on new types.
  • Each renderer loads only when that type appears.

Cons

  • Two paths to keep consistent: the rich one and the fallback.

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.

tsx
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>
  );
}
A registry: each type maps to a lazily loaded renderer; unknown types get the fallback.

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?

Every id the client has

POST /notifications/read with the 40 loaded ids.

Pros

  • Exact: only items the user could see.

Cons

  • Misses the other 300 unread items, which the user meant to clear.
  • Payload grows with the list.

"Mark everything" with no parameter

POST /notifications/read-all with an empty body; the server marks every unread item.

Pros

  • Tiny request; covers all 340.

Cons

  • Races with arrivals: a mention that lands 200 ms after the click, before the server runs the update, gets marked read although the user never saw it.

A watermark

POST /notifications/read-all with { upTo: "2026-10-20T09:14:03.120Z" }, the updatedAt of the newest item the client was showing.

The server marks read everything updated at or before upTo, and nothing newer.

Pros

  • Tiny request; covers all 340.
  • Items that arrive during the request stay unread, so nothing is lost.

Cons

  • Needs a per-user readAllUpTo time on the server, combined with per-item read flags.

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.

Timeline of Mark all as read. Items n1, n2, and n3 exist when the user clicks; the client sends upTo equal to n3's time. Item n4 arrives while the request is in flight. The server runs the update after n4 arrived. With the watermark, n1 to n3 become read and n4 stays unread. Without it, n1 to n4 all become read, and n4 is lost although the user never saw it.timen1n2n3click: upTo = n3n4arrivesserver runs updatewatermark: n1 to n3 readn4 stays unreadno watermark: n1 to n4 readn4 lost, never seen
The watermark pins the user's intent to what they could see at the moment of the click, no matter when the server gets to run the update.

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.

ts
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 });
  }
}
Mark all as read: optimistic by watermark, with a rollback that touches only what it changed.
Sequence diagram of Mark all as read between the user, the panel, the store, and the API. The user clicks Mark all as read. The panel sets read on every item up to the watermark in the store, and the list re-renders without tints. The panel posts read-all with upTo. Meanwhile the API's stream delivers a created event for a new item, which stays unread. The API replies 200 with seq 907 and count 1. On the failure path, the API replies 500, and the panel undoes only its own changes in the store, and the user sees an inline error with Retry.UserPanelStoreAPIMark all as readread up to upTore-render, no tintsread-all { upTo }event: createdseq 907, count 1or, on failure:500 errorundo own idserror, Retry
The optimistic step and the server use the same rule, so they agree on which items changed. On failure the client restores only the ids it changed, so an item read on another device meanwhile stays read.

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?

Always send both

Every notification goes to the live stream and to push.

Pros

  • Simple; nothing is ever missed.

Cons

  • Duplicates whenever a tab is open, which is most of the working day.
  • Users turn push off within a week.

The service worker checks for open tabs

On push, call clients.matchAll({ type: "window" }); if a tab is visible, post the event to it instead of showing a notification.

Pros

  • Decided on the device, with exact knowledge of tab visibility.

Cons

  • userVisibleOnly means each push should show a notification; skipping it risks the browser showing its own generic one.
  • Can't stop the phone from buzzing.

The server routes by presence, with a delay

Each open tab reports activity (visible and recently used) over its live connection.

If the user is active somewhere, send in-app only. If not, wait about 30 seconds, and send push only if the item is still unread.

Pros

  • One decision for all devices.
  • The delay catches "I'm about to look anyway" and saves a buzz.

Cons

  • Presence must be tracked per connection.
  • Push for idle users arrives a little later.

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.

Sequence diagram of push routing between the server, the browser's push service, the service worker, the operating system tray, and a tab. When the user is active, the server sends only a live event to the tab. When the user is idle for 30 seconds and the item is still unread, the server sends a push to the push service, which delivers a push event to the service worker. The worker calls showNotification with the group tag. A click in the tray sends notificationclick to the worker, which focuses a tab and opens the item's URL. The tab posts read to the server, and if the item had been read elsewhere, the server can tell the worker to close the notification.ServerPush serviceWorkerOS trayTabactive: liveidle 30 spush eventshow(tag)clickfocus, openPOST readpush: close
One decision point, on the server, picks in-app or push, so the same mention doesn't appear twice. The tag makes a later push for the same group replace the earlier one in the tray.
js
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);
  })());
});
Service worker: show grouped notifications, and on click focus an open tab instead of opening another.

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.

  1. Wait for a moment where push clearly helps: the user just asked a question in a thread, or requested a review.
  2. Show our own small, dismissible in-app card: "Get notified when someone replies, even with this tab closed?" with Turn on and Not now.
  3. Only on Turn on, call Notification.requestPermission() from that click.
  4. 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.
  5. 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.

Defaults for a new user. Locked cells can't be turned off: in-app is the record of what happened, and security alerts must always arrive.
Type groupIn-appPushEmail
Mentions and replieson, lockedoninstant
Review requestson, lockedoninstant
Likes and reactionsonoffdaily digest
New followersonoffweekly digest
Security alertson, lockedoninstant, locked
Wireframe of the notification preferences page. A matrix has columns In-app, Push, and Email, and four rows. Mentions and replies: in-app locked on, push on, email instant. Likes and reactions: in-app on, push off, email daily digest. New followers: in-app on, push off, email weekly digest. Security alerts: in-app locked, push on, email locked. Below, a muted threads line lists 2 threads with Unmute, and a note says each checkbox is named by row and column.Settings: notificationsIn-appPushEmailMentions and replieslockedoninstantLikes and reactionsonoffdailyNew followersonoffweeklySecurity alertslockedonlockedMuted threads: post 123, PR 88 (Unmute)checkbox name: row + column
Rows are types, columns are channels. Locked cells explain themselves on hover; every checkbox's accessible name joins its row and column, like Likes and reactions, push.

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.

PieceLoadsResponsibility
BellButtonWith the shellRenders the icon, the badge text, and the accessible name; toggles the panel; prefetches the panel chunk on hover or focus.
badgeStoreWith the shellHolds { count, seq }; applies server and tab values, newest seq wins. A plain module with a subscribe function, read by React with useSyncExternalStore.
liveConnectionWith the shellOpens 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.
tabSyncWith the shellA BroadcastChannel that shares counts and local writes between tabs.
notificationStoreWith the shell, emptyNormalized 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.
NotificationPanelLazy chunkPopover, filters, time sections, list with infinite scroll, Mark all as read.
Renderer registryLazy, per typeOne component per type, plus the generic fallback.
Action handlersLazy, per typeAccept, Decline, Reply: requests with their own pending and error states.
Service workerRegistered after opt-inReceives push, shows system notifications, handles clicks.
Preferences pageIts own routeEdits the matrix and the push subscription.
Architecture of the notification center. An always-loaded core of about 3 KB holds BellButton, badgeStore, tabSync, liveConnection, and notificationStore. badgeStore feeds BellButton; tabSync shares values with badgeStore; liveConnection updates badgeStore and notificationStore and receives events from the stream server. A lazy chunk, loaded on first open, holds the NotificationPanel, the renderers, and the action handlers; notificationStore feeds the panel, which uses the renderers and actions and calls the REST API for pages and writes. On the server side are the SSE stream and the REST API, which also sends push to the service worker, registered after opt-in.always loaded, about 3 KBBellButtonbadgeStoretabSyncBroadcastChannelliveConnectionnotificationStorelazy chunk, first openNotificationPanelRenderersone per typeAction handlersserverStream (SSE)sends eventsREST APIpushService worker
Everything that must run all day sits in the small core and works without the panel mounted. The panel, its renderers, and its actions arrive only when the user first opens the bell.

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.

Data flow of the notification center in four columns: server, client data layer, stores, and UI. The REST API sends JSON pages to fetch, and fetch passes them to apply. The SSE stream sends events with seq and count to apply. apply upserts items into the items store and sets the count in the badge store. The items store feeds the Panel; the badge store feeds the Bell and shares values with other tabs over BroadcastChannel. Writes go back from the Panel to the REST API: seen, read, read-all, and actions.REST APISSE streamfetchapply(ev)seq checkitems storebadge storePanelBellother tabsBroadcastChannelwrites: seen, read, actionsserverdata layerstoresUI
Pages and live events take the same door, 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.

ts
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;
};
What the client stores. Actors are capped; the total tells the UI how many "others" there are.
The client's data model. The notification store holds byId, a map of items by id, three lists for All, Unread, and Mentions, each with ids and a cursor, plus the seen watermark and the last seq. A separate badge store holds count and seq. Each Notification has id, type, and group key; up to 3 actors and an actor count such as 40; a target and a URL; fallback text and image; created and updated times; a read flag; and optional actions such as Accept.notificationStorebyId: items by idall: ids + cursorunread: ids + cursormentions: ids + cursorseenUpTo, lastSeqbadgeStore{ count, seq }Notificationid, type, groupKeyactors: first 3actorCount: 40target, urltext, image: fallbackcreatedAt, updatedAtread: booleanactions?: Accept
Lists hold only ids, so one item appears once in memory however many filters show it. There is no per-item seen flag: seen comes from comparing updatedAt with the watermark.

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.

http
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: "..." } }
The REST surface. Every write returns the new seq and count.

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.

ts
// 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"
}
An aggregate sentence built from actors and a count: ICU plurals for "others", Intl.ListFormat for the list.

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:

MinutesWhat you doWhat you draw
0 to 5Ask the scope questions: volume, seen versus read, multi-tab and devices, inline actions, push.Nothing yet; write the numbers down.
5 to 10Name 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 20The 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 30The 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 38Mark one and mark all, with the watermark and the race.The mark-all timeline.
38 to 45Push 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?"

Think first

"The user opens the laptop after a week. What do they see in the first second?"

Think first

"How would you test that the badge never drifts?"

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 unseenCount and a seq; tabs replace, never add, and share values over BroadcastChannel, 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 updatedAt with 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next