Frontend System Design/Real-time Updates
Lesson 17 of 17 · Episode 17

Real-time Updates — Client Pulling

Short polling, long polling, and when pulling beats pushing.

PollingLong pollingReal-time
Watch on YouTube ↗

The simplest family of real-time is client pull: if the server can't knock, the client keeps asking. Two variants — short polling and long polling — trade wasted requests against latency. Both are workarounds for the same HTTP limit.

See the difference

Short vs. long polling

Watch both run live. Short polling fires on a fixed interval — most requests come back empty (wasted), and latency is capped by the interval. Long polling lets the server hold the request open and answer the instant data appears — almost no waste, near-zero latency. Toggle, or flip to Autoplay:

🖥
Client
🗄
Server
Requests sent
0
Wasted (empty)
0
Fixed interval · latency = the interval · lots of empty polls.
Variant 1

Short polling, in code

The naive version is about ten lines: ask on a timer, render whatever comes back, repeat.

naive short polling — and it's buggy
useEffect(() => {
  const id = setInterval(async () => {
    const res = await fetch("/api/messages");
    setMessages(await res.json());
  }, 5000);
  return () => clearInterval(id);
}, []);

The latency here equals your interval: a 5s interval means updates can be up to 5s stale. Shrink the interval and latency drops — but server load climbs. That tension is the whole game.

Production reality

Four pitfalls of naive polling

That ten-line version breaks in real apps in at least four ways — here's each one as a picture.

1 · Race conditions

Poll takes 3s, interval is 2s → two requests in flight, and responses arrive out of order:

ClientServer
Request #4
slow · 3s
Request #5
fast · 1s
Response #5
arrives FIRST · ✓ fresh
Response #4
arrives LAST · ✕ stale
The UI renders #4 on top of #5 → the stale response wins.
Fix: sequential setTimeout recursion (never overlap), or cancel the in-flight request with AbortController before the next one.

2 · Polling on a hidden tab

The user switches tabs, but your interval keeps hammering the server for nothing — and drains mobile battery:

● tab visible
GmailYour appDocs
Your app is in front — document.hidden === false. Polling is useful.
app
server
Wasted polls 0
Battery100%
The fix
Pause when document.hidden is true via the Page Visibility API, and resume on visibilitychange. Flip the toggle to feel it.

3 · No backoff on errors

The server is down, so the naive version keeps hitting it at the same rate, making recovery harder:

● 503 · DOWN
3 of 10,000 clients · all retry in sync
Thundering herd: synchronized retries keep pounding the dead server — it never gets room to recover.

4 · Re-rendering identical data

The response hasn't changed, but you re-render the whole list anyway:

200 · full re-render
Wasted re-renders
0
flicker · scroll jumps
Identical data, but the whole list re-mounts every poll — wasted bandwidth, flicker, lost scroll position.
The fix
Send ?since=<lastId> so the server returns 304 or [] when nothing's new. Use an ETag, or diff before touching the DOM.

Fix all four together and the “ten lines” grow into a small but robust poller — sequential recursion (no overlap), visibility pausing, backoff with jitter, and a since-cursor:

production-ready poller
function createPoller({ url, interval = 5000, onData }) {
  let stopped = false, failures = 0, lastSeen = null, controller;

  const tick = async () => {
    if (stopped) return;
    if (document.hidden) return schedule(interval);        // pitfall 2
    controller = new AbortController();                    // pitfall 1
    try {
      const res = await fetch(`${url}?since=${lastSeen ?? ""}`,
        { signal: controller.signal });                    // pitfall 4
      const data = await res.json();
      if (data.length) { lastSeen = data.at(-1).id; onData(data); }
      failures = 0; schedule(interval);
    } catch {                                              // pitfall 3
      const wait = Math.min(60000, interval * 2 ** ++failures);
      schedule(wait * (0.8 + Math.random() * 0.4));        // backoff + jitter
    }
  };
  const schedule = (ms) => { if (!stopped) setTimeout(tick, ms); };
  tick();
  return () => { stopped = true; controller?.abort(); };
}
Tip
You rarely hand-roll all this — TanStack Query and SWR ship a refetchInterval that handles visibility, focus, backoff, and dedup for free. But knowing the failure modes is what an interview tests.
Don't underestimate it

When short polling wins

Despite the trendy dismissals, short polling is genuinely the right call a lot of the time. Tap each:

The one limit
Short polling's floor is the interval: latency can never beat it. Need lower latency without that floor? That's exactly what long polling buys you.
Variant 2

Long polling: hold the line

Long polling fixes short polling's biggest flaw — wasted requests. The client sends a request; instead of answering “nothing yet,” the server holds it open until either new data arrives (answer immediately) or a timeout hits (answer empty). The client then re-sends right away.

What you gain, what you pay
Latency drops to roughly server-processing time (no interval wait), and wasted requests fall to near zero. The price: the server now holds state — an open connection per client — which complicates scaling.
The hidden cost

Stateless vs. stateful

Short polling is stateless: each request stands alone, so any server can answer and scaling is trivial. Long polling is stateful — a held connection lives on one machine. The canonical failure is a file upload: it's at 55% when that machine dies, and the next server has no idea.

First request
User
LB
Server A
Upload 55% — Server A holds the state.
Reconnect after the connection drops
User
LB
Server B
Upload 0% — Server B has no idea!
While it holds the request, the server has context the database doesn't — the subscription, the timeout, the partial progress. Lose that server and you lose all of it.

Why that matters comes down to how you scale:

Vertical · scale UP
BIG
One machine — more CPU / RAM / disk.
− Hard ceiling · single point of failure · cost grows non-linearly.
Horizontal · scale OUT
+ more…
Many interchangeable machines.
+ No ceiling · survives failures · linear cost — but needs statelessness.
Horizontal scaling needs interchangeable, stateless servers so a request can land anywhere. A held long-poll connection carries state — which is exactly what breaks it.

So to keep long polling working across many servers, you need one of two fixes:

Client 1
Client 2
LB
Server A
Server B
Send a request to see how the load balancer routes it.
Pros
+ Easy — most LBs support it out of the box
+ No new infrastructure to run
Cons
Uneven load — hot clients pile up
Server dies → its state is still lost
DDoS amplification risk
Best for short-lived, non-critical state (file uploads). It still loses in-flight state on a crash.
Bridge to the backend
A choice that's “10 lines” on the frontend can be a major backend complication. Strong frontend engineers understand the other side well enough to weigh that trade-off — not just pick the tool.
Side by side

The cheat sheet

Short pollingLong polling
Latency= interval + round-trip≈ server processing (near-zero)
Wasted requestsMany empty polls~none
Server stateStateless — trivial to scaleStateful — holds open connections
ComplexityDead simpleMore moving parts (state, balancing)
Best whenInfrequent updates; staleness OKLower latency without a new protocol
Tip
Don't dismiss short polling as primitive — it's simple, adds no load when used appropriately, and is a perfect fallback. WebSockets themselves fall back to long polling when a connection fails.
Practice

Check your understanding

Q1Multiple choice
In short polling with a 5-second interval, the update latency is roughly…
Q2Multiple choice
What is long polling's main advantage over short polling?
Q3Sort each scenario
Which polling pitfall does each fix address?
A failing endpoint being hammered every interval
Polling continuing in a background tab
Re-rendering the same unchanged list
Try it yourself

Design challenge

Design polling for a “new notifications” badge:

  1. Short or long polling — and why, given the staleness window?
  2. Sketch how you'd add backoff, visibility-pausing, and a since-cursor.
  3. If you chose long polling, where does the held state live so a server crash doesn't lose it?
Key takeaways
  • Client pull works around HTTP by having the client keep asking.
  • Short polling: fixed interval, simple and stateless, but wasted requests and latency = interval.
  • Naive polling needs race-condition handling, visibility pausing, backoff + jitter, and since-cursors.
  • Long polling: server holds the request → near-zero latency and waste, but it's stateful (sticky sessions or a shared store).
  • Short polling is a great fallback — even WebSockets fall back to it.
← Previous
16. Real-time Updates — Introduction