Real-Time Feed
Design a feed that receives live updates over a persistent connection, merges them into a paginated list without disturbing the reader, and recovers cleanly from disconnection.
Advertisement
The Problem
Design a feed that updates live. The user loads a paginated list of posts, and as new posts are created - by other users, right now - they arrive without a refresh. Likes and comment counts on visible posts update in place. The user can post themselves and see their post appear instantly.
Everything from Infinite Scroll still applies to the paginated half of this list. What is new is a second source of truth arriving asynchronously, and that changes three things: you need a transport for server-initiated messages, a merge strategy that reconciles a live stream against a paginated list without disrupting the reader, and a recovery story for the connection dropping, which on mobile is a routine event rather than an edge case.
Requirements
Functional
- New posts arrive without user action and are surfaced non-disruptively.
- In-place updates (likes, comment counts, edits, deletions) apply to already-rendered items.
- The user's own actions appear immediately, and revert visibly if the server rejects them.
- Pagination continues to work while live updates are arriving.
- Connection state is visible to the user when it is degraded.
Non-functional
- Reconnection is automatic, with backoff, and does not stampede the server after an outage.
- No message is silently lost across a disconnection, or the client resynchronises and says so.
- Duplicate or out-of-order deliveries are absorbed without visible artefacts.
- Live updates never move content the user is reading.
- The connection does not keep the device's radio awake while the tab is hidden.
Choosing a Transport
Three options, and the honest answer is that the least fashionable one is often correct.
Polling
Ask on an interval: setInterval(() => fetch('/api/feed/updates?since=' + cursor), 5000).
Genuinely underrated. It requires no new infrastructure, works through every proxy, uses ordinary HTTP caching and auth, is trivially debuggable, and fails safe - a dropped request is just a slightly later update. Its cost is the interval: latency averages half the interval, and most requests return nothing. A hundred thousand clients polling every 5 seconds is 20,000 requests per second of mostly empty responses.
Long polling improves the latency side: the server holds the request open until it has something to say (or times out at ~30s), then the client immediately reissues. You get near-real-time delivery with plain HTTP semantics, at the cost of a held connection per client - which is the same resource cost as SSE, without SSE's ergonomics.
Polling is the right choice when updates are infrequent, when staleness of a few seconds is acceptable, or when you do not yet have the operational maturity to run persistent connections. Reaching for WebSockets to deliver an update every few minutes is over-engineering.
Server-Sent Events
A single long-lived HTTP response streaming text/event-stream. Unidirectional, server to client.
const source = new EventSource('/api/feed/stream');
source.addEventListener('post.created', (event) => {
const post = JSON.parse(event.data) as Post;
bufferIncomingPost(post);
});
source.addEventListener('post.updated', (event) => {
applyPatch(JSON.parse(event.data) as PostPatch);
});
// The browser reconnects automatically and resends Last-Event-ID.
source.onerror = () => setConnectionState('reconnecting');
SSE's advantages are mostly about inheritance. It is HTTP, so cookies, Authorization headers, compression, HTTP/2 multiplexing, CDN behaviour and your existing tracing all work unchanged. And two hard problems come free: automatic reconnection with a server-tunable retry delay, and replay - if each event carries an id: field, the browser resends the last one as Last-Event-ID on reconnect, and a server that keeps a short buffer can replay the gap without any client code.
Its limits: it is server-to-client only, text-only, and on HTTP/1.1 it consumes one of the browser's ~6 connections per origin, which matters if a user opens several tabs. Over HTTP/2 that constraint largely disappears.
WebSockets
A protocol upgrade to a persistent, bidirectional, message-framed connection.
The right choice when the client genuinely needs to send on the same connection at low latency: typing indicators, presence, cursor positions, collaborative editing. For a read-only feed, choosing WebSockets means writing reconnection, backoff, replay, heartbeats and auth-refresh yourself - all of which SSE either provides or makes trivial.
Two operational realities deserve mention because interviewers ask. Authentication is awkward: the browser's WebSocket constructor cannot set headers, so tokens end up in the query string (logged by proxies) or in a first message after connect (requiring a server-side pre-auth window). And intermediaries are hostile to idle connections: corporate proxies and load balancers reap connections with no traffic, which is why heartbeats are mandatory rather than nice to have.
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Request/response | Server to client | Bidirectional |
| Latency | Half the interval | Near-immediate | Near-immediate |
| Reconnect | Inherent | Automatic, built in | Hand-written |
| Replay of missed events | Via since cursor | Native Last-Event-ID | Hand-written |
| Auth | Standard HTTP | Standard HTTP | Awkward |
| Proxy friendliness | Total | High | Variable |
| Server cost per client | Repeated requests | One held connection | One held connection |
For a feed that is read-heavy with occasional writes over ordinary HTTP endpoints, SSE is the default and WebSockets are the upgrade you take when you add a genuinely bidirectional feature. This is the transport-selection reasoning from Networking and Data Fetching applied to server-initiated data, which is the one case plain request/response cannot express.
Connection Lifecycle
Whatever the transport, the connection will drop. Wi-Fi to cellular handover, a tunnel, a laptop lid, a proxy timeout, a deploy rolling the server fleet. The lifecycle is the design.
Diagram100%sequenceDiagram participant C as Client participant S as Feed Server C->>S: connect (lastSeq = 412) S-->>C: ack, replay 413..415 C->>C: apply 413..415, lastSeq = 415 loop steady state S-->>C: event seq 416, 417 ... S-->>C: ping (every 25s) C->>C: reset heartbeat timer end Note over C,S: network drops - no close frame C->>C: heartbeat timer expires (30s) C->>C: force close, state = reconnecting C->>C: wait 1s + jitter C->>S: reconnect (lastSeq = 417) Note over C,S: server unreachable C->>C: wait 2s, 4s, 8s ... capped 30s (all jittered) C->>S: reconnect (lastSeq = 417) S-->>C: gap too large - buffer starts at 512 C->>C: discard live state, refetch page 1, reset lastSeqvisualized by
Four mechanisms are visible there, and each exists because of a specific failure.
Heartbeats, because a dead connection often does not announce itself. TCP only discovers a broken path when something is sent, so a socket can sit in OPEN while messages go nowhere - the worst failure mode, since the UI reports "live" while the feed is frozen. The server pings on a fixed interval; the client arms a timer for somewhat longer and treats expiry as death.
const HEARTBEAT_INTERVAL_MS = 25_000;
const HEARTBEAT_GRACE_MS = 10_000;
function armHeartbeat(socket: WebSocket) {
clearTimeout(heartbeatTimer);
heartbeatTimer = setTimeout(() => {
// Do not wait for a close event that may never come.
socket.close(4000, 'heartbeat timeout');
}, HEARTBEAT_INTERVAL_MS + HEARTBEAT_GRACE_MS);
}
Exponential backoff, because immediate retry loops are how a client turns a brief server hiccup into a sustained outage.
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 30_000;
function nextDelay(attempt: number) {
const exponential = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
// Full jitter: without it, every client that dropped together retries
// together, and the server gets a synchronised stampede on recovery.
return Math.random() * exponential;
}
Jitter is the part people leave out and the part that matters most. If a server restarts and fifty thousand clients all reconnect at exactly 1s, 2s, 4s, the retry schedule is a thundering-herd attack on your own infrastructure. Randomising across the window spreads it flat.
Sequence numbers, because "reconnected" and "caught up" are different states. Each event carries a monotonic seq; the client tracks the highest it has processed and sends it on reconnect. Three outcomes must be handled:
type ResumeResult =
| { status: 'resumed'; events: FeedEvent[] }
| { status: 'gap-too-large'; bufferStartsAt: number };
function onResume(result: ResumeResult) {
if (result.status === 'resumed') {
// Idempotent by construction: anything at or below the high-water mark
// has already been applied, so replays and duplicates are safe.
for (const event of result.events) {
if (event.seq <= lastSeq) continue;
applyEvent(event);
lastSeq = event.seq;
}
return;
}
// The gap exceeds the server's buffer. Guessing is worse than resyncing.
resetLiveState();
refetchFirstPage();
}
The gap-too-large branch is the one candidates skip, and it is the one that decides correctness. A client that reconnects and quietly resumes after a fifteen-minute gap has a permanent hole in its list, and no later event will fill it. Discarding live state and refetching page one is the honest recovery.
Visibility awareness, because a hidden tab holding a socket open keeps the radio warm and drains battery for updates nobody is looking at.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Give it a grace period - tab switches are constant.
idleTimer = setTimeout(() => disconnect(), 60_000);
} else {
clearTimeout(idleTimer);
// Reconnecting resumes from lastSeq, so nothing is lost by disconnecting.
if (!isConnected()) connect();
}
});
This is only safe because of sequence-based resume: you can afford to drop the connection precisely because you know how to catch up.
Connection state also belongs on screen. A subtle "Reconnecting…" indicator when the socket is down for more than a couple of seconds prevents the worst outcome - a user reading a silently frozen feed and believing it is current. And these are the events worth reporting to your monitoring, per Observability: reconnect rate, time-to-reconnect, resync-required count and heartbeat-timeout count are the four numbers that tell you whether your real-time layer is healthy, and none of them are visible from the server side alone.
Merging Live Events into a Paginated List
Now the interesting half. The list has two sources: pages fetched by cursor, and events arriving live. They must reconcile into one ordered list without disrupting the reader.
Diagram100%flowchart TB E["Incoming event"] --> T{"event type"} T -->|"post.updated / post.deleted"| U{"is the item<br/>currently rendered?"} U -->|"no"| DROP["drop - the next fetch<br/>will carry the change"] U -->|"yes"| PATCH["patch in place<br/>(no reorder, no shift)"] T -->|"post.created"| A{"authored by<br/>this user?"} A -->|"yes"| OPT["reconcile with the<br/>optimistic entry by clientId"] A -->|"no"| P{"is the reader at<br/>the top of the list?"} P -->|"yes"| PRE["prepend directly<br/>(nothing to displace)"] P -->|"no"| BUF["push to buffer,<br/>increment 'N new posts'"] BUF --> BAN["banner tapped or<br/>user scrolls to top"] BAN --> FLUSH["flush buffer:<br/>dedupe by id, sort, prepend"] style DROP fill:#3f2d1e,stroke:#f59e0b style PATCH fill:#1e3f2d,stroke:#22c55e style BUF fill:#1e3a5f,stroke:#3b82f6 style FLUSH fill:#1e3f2d,stroke:#22c55evisualized by
In-place updates are the easy case. A like count or an edited body changes one item's fields without changing list order or geometry, so it can be applied immediately. If the item is not currently in the list, drop the event - a patch for something you are not showing is noise, and the next fetch will carry the current value. Deletions are marginally trickier: removing a row does shift content. Above the reader's position, prefer tombstoning ("This post was deleted") over removal until they scroll away, so the page does not jump.
New items are the hard case, because insertion at the top moves everything below it. Prepending three posts while someone is mid-paragraph pushes their text off screen. Scroll anchoring reduces this but does not solve it, and on a busy feed the list becomes unreadable.
The standard answer is a buffer plus banner:
type FeedState = {
items: Post[]; // rendered, in order
buffer: Post[]; // arrived live, not yet shown
lastSeq: number;
};
function onPostCreated(state: FeedState, post: Post): FeedState {
const isKnown =
state.items.some((item) => item.id === post.id) ||
state.buffer.some((item) => item.id === post.id);
if (isKnown) return state; // replay or duplicate delivery
// At the top, insertion displaces nothing the user is reading.
if (isScrolledToTop()) {
return { ...state, items: [post, ...state.items] };
}
return { ...state, buffer: [post, ...state.buffer] };
}
function flushBuffer(state: FeedState): FeedState {
const seen = new Set(state.items.map((item) => item.id));
const fresh = state.buffer
.filter((item) => !seen.has(item.id))
.sort((a, b) => b.createdAt - a.createdAt);
return { ...state, items: [...fresh, ...state.items], buffer: [] };
}
Three properties of this shape matter. It batches: a viral moment producing 200 posts a minute becomes one banner and one splice, not 200 DOM insertions. It gives the user control over when their list reorders. And it is idempotent - the isKnown check makes replay after reconnect harmless, which is what lets you resume aggressively.
The buffer needs a cap. Holding 5,000 live posts for a reader who has been idle for an hour is pointless memory; past a few hundred, drop the buffer, keep the count approximate ("500+ new posts"), and on tap discard live state and refetch page one - the same resync path as gap-too-large.
One subtlety about pagination. Once live events are prepended, a cursor derived from the first item is meaningless for paging forward; forward pagination must always anchor to the last item's cursor. This is exactly why cursor pagination is required here and offset is not merely suboptimal but broken: with live inserts, offsets shift constantly. Live items also arrive newer than the newest page, so they occupy a separate region of the ordering and never conflict with the pages below - which is what keeps the merge tractable.
Both halves of this state belong in the same store. A feed where the paginated list lives in a query cache and live events live in component state will drift, because an event can arrive for an item the cache is about to refetch. Treat the live stream as writes into the same cache that pagination populates - the single-owner principle from State Management Architecture.
Optimistic Updates and Rollback
When the user likes a post or publishes one, waiting for a round trip makes the app feel slow. Apply the change locally, send it, and reconcile.
async function likePost(postId: string) {
const previous = readPost(postId);
if (!previous) return;
// 1. Apply immediately - the user sees their action land.
patchPost(postId, {
likedByMe: true,
likeCount: previous.likeCount + 1,
pending: true,
});
try {
const confirmed = await api.like(postId);
// 2. Reconcile with the server's authoritative counts. Another user may
// have liked it in the meantime, so do not trust the local arithmetic.
patchPost(postId, { ...confirmed, pending: false });
} catch (error) {
// 3. Invert only our own change. Do not restore a whole snapshot: a live
// event may have legitimately changed this item since step 1.
patchPost(postId, {
likedByMe: previous.likedByMe,
likeCount: Math.max(0, readPost(postId)!.likeCount - 1),
pending: false,
});
showToast('Could not save your like. Try again.');
}
}
The comment on step 3 is the whole lesson. The tempting implementation snapshots the item (or the list) before mutating and restores it on failure. On a live feed that is wrong: between step 1 and step 3, an event may have arrived incrementing the count from someone else, or edited the body. Restoring the snapshot throws that away and shows stale data. Roll back by applying the inverse of your own change, then let the next authoritative payload correct any drift.
Composing a new post needs one extra mechanism, because the optimistic entry and the live event describe the same post with different identities:
async function createPost(body: string) {
// A client-generated id lets us recognise our own post when it returns
// through the live stream with a server id.
const clientId = crypto.randomUUID();
prependOptimisticPost({
id: `optimistic:${clientId}`,
clientId,
body,
author: currentUser,
createdAt: Date.now(),
status: 'sending',
});
try {
const saved = await api.createPost({ body, clientId });
replaceByClientId(clientId, { ...saved, status: 'sent' });
} catch {
markByClientId(clientId, { status: 'failed' });
}
}
Without clientId, the user's own post arrives over the socket as a stranger and renders twice - once optimistically, once live. The event handler must check clientId against pending optimistic entries and replace rather than insert. The same identifier also makes the server write idempotent: a retried create with a clientId the server has already seen returns the existing post instead of creating a second one, which is what saves you when a request succeeds but its response is lost.
Failed posts should stay visible with a retry, not vanish. Silently discarding user-authored content is the worst failure available to this component.
Common Interview Follow-Up Questions
"A celebrity posts and your feed receives 500 events per second. What breaks and what do you do?"
The client breaks before the transport does: 500 state updates a second means 500 re-render cycles, and the main thread saturates. Three layers of defence. Coalesce on the client - accumulate events in a plain array and flush into state on a requestAnimationFrame or a 250ms tick, so many events become one render. Cap the buffer and switch to an approximate count. Then push work to the server: it should batch events into arrays before sending, collapse repeated updates to the same item into one message, and for extreme fan-out drop from event-carried-state to event-notification ("something changed, refetch when convenient"), which shifts load to a cacheable HTTP path. Coalescing is also what keeps INP healthy under load, per Performance Engineering.
"The user has the feed open in three tabs. What is wrong with that and how do you fix it?"
Three sockets per user, three times the server fan-out, three copies of the same events, and read state that diverges between tabs. The fix is to elect one connection and share it: a SharedWorker holds the single socket and broadcasts to tabs, or one tab becomes leader and relays over a BroadcastChannel with a heartbeat so another tab takes over if the leader closes. The same cross-tab machinery is what keeps unread counts consistent in Notification System.
"How do you test a real-time feed?"
Make the transport an interface, then test against a fake one you can drive frame by frame - the tests that matter are the ones asserting behaviour under events real servers rarely produce on demand: out-of-order sequences, a duplicate delivery, a gap-too-large resync, a heartbeat timeout with no close event, and a rejection arriving after a live event has already changed the item. Reducer-level tests cover the merge logic cheaply since it is pure; integration tests cover banner-to-flush and optimistic-to-confirmed. Backoff and heartbeat need fake timers. The layering argument is in Testing Strategy.
"The auth token expires while the socket is open. What happens?"
Nothing, until the server decides otherwise - which is the problem. A connection authenticated at handshake stays open indefinitely on a credential that is no longer valid, so the server must enforce expiry on the live connection: close with a specific code when the token expires, and let the client refresh and reconnect (resuming from lastSeq, so nothing is lost). The alternative is an in-band re-auth message that refreshes the connection's credential without dropping it. Either way, an expiring token must reach the socket layer rather than only the fetch layer - the session-lifetime reasoning in Security Architecture applies to persistent connections too, and is easy to forget precisely because they were authenticated once and appear fine.
"Can you server-render a real-time feed?"
Yes, and you should. Server-render page one so first paint has real content, then open the connection after hydration, passing the sequence number the server embedded in the payload as the starting lastSeq. Without that handoff there is a gap between "HTML generated" and "socket connected" in which events are lost - a race that is invisible locally and reliably reproducible on a slow connection. The hydration boundary and its costs are in Rendering Architecture.
Tradeoffs Table
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Interval polling | No new infrastructure, works everywhere, fails safe, trivially debuggable | Latency is half the interval; most responses are empty | Infrequent updates, or before you can operate persistent connections |
| SSE | HTTP semantics for auth and proxies, automatic reconnect, native replay via Last-Event-ID | Server to client only, text only, one connection per tab on HTTP/1.1 | Default for read-mostly live feeds |
| WebSocket | Bidirectional, low latency both ways, binary frames | Reconnect, replay, heartbeats and auth all hand-written; proxy-hostile when idle | When the client must also send at low latency - presence, typing, collaboration |
| Prepend live items directly | Simplest; the list is always current | Moves content the reader is looking at; unbounded DOM insertions under load | Only when the user is already at the top of the list |
| Buffer plus "new posts" banner | Reader is never displaced, updates batch naturally, replay-safe | Extra state and a control to build; list is deliberately not live | Default for any feed a user reads rather than watches |
| Optimistic update with inverse rollback | Instant feedback, survives concurrent live changes to the same item | More code than snapshot-restore; needs client ids for creates | Any user action on a live list - likes, follows, posting |
Where This Applies
This design is where three foundations meet. Choosing a transport for server-initiated data is the part of Networking and Data Fetching that plain request/response cannot express. Treating the live stream as writes into the same cache that pagination fills - rather than a second, competing copy of the list - is the single-owner rule from State Management Architecture. And a real-time layer is effectively unobservable from the server alone: reconnect rate, time-to-reconnect and resync count only exist on the client, which is why they belong in the instrumentation described in Observability.
Its parts recur across this track. The paginated half is Infinite Scroll, and the cursor discipline established there is what makes live merging tractable. The transport and reconnect machinery is reused wholesale by Notification System, which adds push delivery for a closed app, and by Dashboard with Widgets, where one connection fans out to many independent panels. Optimistic updates with rollback reappear in Shopping Cart, and the same local-first-then-reconcile instinct, taken to its conclusion, becomes the merge problem in Collaborative Document.
Advertisement
When should you choose SSE over WebSockets for a live feed?
When the data flows only from server to client, which is the case for most feeds. SSE runs over plain HTTP, so it inherits everything you already have - compression, HTTP/2 multiplexing, standard authentication cookies and headers, proxies that understand it, and observability tooling that can see it. It also reconnects automatically and replays missed events natively through the Last-Event-ID header, which is machinery you would otherwise write by hand. WebSockets are the right answer when you genuinely need low-latency client-to-server messages on the same connection - typing indicators, cursor positions, collaborative editing, a chat compose box - or when you need to send binary frames. Choosing WebSockets for a read-only feed means reimplementing reconnection, replay and auth for capability you are not using.
Why is prepending live items directly into the list usually wrong?
Because it moves content the user is currently reading. Inserting three posts at the top pushes everything down by their combined height, so the paragraph someone was mid-way through jumps off screen. Browser scroll anchoring mitigates this but does not eliminate it, and on a busy feed the list becomes unusable. The standard solution is a "new posts" banner - buffer arriving items in memory, show a count, and splice them in only when the user asks or when they are already at the top of the list where insertion is harmless. This also gives the user control over a feed that would otherwise reorder itself continuously, and it turns an unbounded stream of DOM insertions into one batched update.
What is a rollback in an optimistic update, and why is capturing the previous state not enough?
A rollback restores the UI when the server rejects a change you already displayed as successful. Capturing the previous state before mutating is the standard technique, but a naive snapshot-and-restore is wrong when other changes have landed in the meantime - if a live event arrives between your optimistic like and the server's rejection, restoring the whole snapshot discards that event too. The safer form is a targeted inverse operation that undoes only your specific change, or a version-aware merge where each item carries a version and the rollback applies only if the item has not otherwise changed. In practice, most systems combine a per-item snapshot with a final reconciliation against server state so that whatever the rollback misses, the authoritative payload corrects.
How does a client detect and recover messages missed while disconnected?
Every event carries a monotonically increasing sequence number or an opaque cursor, and the client stores the last one it successfully processed. On reconnect the client sends that value, and the server replays everything after it from a short-lived buffer. Three cases need handling. If the gap is small, the server replays and the client resumes seamlessly. If the gap is larger than the server's buffer, the server says so and the client must resynchronise by discarding its live state and refetching page one - a full resync is correct and honest, whereas silently resuming leaves a permanent hole in the list. And if events arrive out of order or twice, the sequence number lets the client discard anything at or below its high-water mark, which is why replay must be idempotent on the client.
Why does a WebSocket connection sometimes die without firing a close event?
Because TCP does not notice a silently dropped path - a mobile network handover, a NAT table timing out, a proxy dropping an idle connection - until something is sent. The socket stays readyState OPEN while messages quietly go nowhere, which is the worst failure mode because the UI shows connected while the feed is frozen. The fix is an application-level heartbeat, where the server sends a ping at a fixed interval and the client arms a timer expecting it, treating a missed interval as a dead connection and forcing a close and reconnect. It matters in both directions, since an idle client connection is exactly what intermediaries reap. This is also why SSE is easier to operate - its reconnection logic is built into the browser and comment-frame keepalives are part of the protocol.