Build it from four parts: a bell with an unread count, a list loaded page by page, a live connection for new items, and read state that the server owns.
Requirements: ask these first.
- What creates notifications? For example likes, comments, mentions or system messages.
- Must they arrive in real time, or is a one-minute delay OK?
- Do we also need browser push when the site is closed, or email?
- Can users mute some types?
UI: a bell with a badge like "9+", and a dropdown with the latest items. A full page shows the whole history. Each item says who did what, when ("2 hours ago"), and links to the thing it is about. Clicking an item marks it as read. There is also a "Mark all as read" button.
Data and API: each notification has an id, type, actor, targetUrl, createdAt and readAt (empty while unread).
GET /notifications?cursor=…returns one page. Use a cursor, a pointer to the last item you received, not page numbers. New items arrive at the top, so page numbers would shift and show duplicates.GET /notifications/unread-countreturns the badge number.POST /notifications/readtakes a list of ids, or "everything up to this id" for mark all. Make it idempotent: sending it twice has the same result as sending it once, so retries are safe.
Real time: the server pushes new items with Server-Sent Events (SSE, a one-way stream from server to browser) or a WebSocket. SSE is enough here, because the browser only listens. If the connection drops, reconnect with growing waits. After reconnecting, ask for everything newer than the last id you have, so nothing is lost. Remove duplicates by id. If live updates are not possible, poll the count every 30 to 60 seconds.
State: keep the list in a server-data cache such as TanStack Query, and add pushed items to the top. Mark items as read optimistically: update the UI first, send the request, and undo it if the request fails. The server's count is the truth, so refetch it after changes. With several tabs open, use BroadcastChannel so reading in one tab updates the badge in the others.
Performance: load the list only when the user opens the dropdown, but keep the count fresh. On the history page, use a virtualized list, which renders only the rows on screen. Group similar items on the server, like "Ana and 3 others liked your post".
Browser push (optional): use the Push API with a service worker. Ask for permission only after the user clicks something like "Turn on alerts", never on page load. Many users block an early prompt, and the browser may not let you ask again.
Accessibility: label the bell with the count, like "Notifications, 5 unread". Announce new items with an aria-live="polite" region, but don't read every single one. Move focus into the dropdown when it opens, and close it with Escape.
Edge cases: the target was deleted, so show "This post is no longer available". Use the server's time for "2 hours ago", because the user's clock can be wrong.
- Ask first what creates notifications, how fast they must arrive, and whether push or email is needed.
- Load the list with cursor pagination, because new items at the top would shift page numbers.
- The server owns the unread count and read state. Mark-as-read requests are idempotent, so retries are safe.
- Push new items with SSE or a WebSocket, then fetch everything after the last id when reconnecting.
- Mark as read optimistically, and sync the badge across tabs with
BroadcastChannel. - Load the dropdown list lazily, virtualize the full history, and group similar items.
- Ask for push permission only after a user action, and make the bell and new items accessible.