Concept
The beginner framing: before reaching for WebSockets or SSE, the simplest way a client can find out about server-side changes is to just... ask repeatedly. Polling means the client sends a normal request every N seconds and checks if anything new is there.
Short polling, simple, but wasteful by construction
setInterval(async () => {
const res = await fetch("/api/notifications/unread-count");
const { count } = await res.json();
updateBadge(count);
}, 5000); // every 5 seconds, REGARDLESS of whether anything changedShort polling is a plain HTTP request on a fixed interval, trivially simple to implement on both ends, works through any infrastructure since it's just ordinary HTTP, and needs zero persistent connection management. The cost is structural: the overwhelming majority of these requests return "nothing changed," yet each one still pays the full cost of a request/response round-trip (connection setup or reuse, headers, a server-side handler invocation, a possible database query), for a feature most users check rarely, that's a lot of wasted work multiplied across every connected client, every interval, all day.
Long-polling, the server HOLDS the request open until there's something to say
// Server:
app.get("/api/notifications/wait", async (req, res) => {
const timeout = setTimeout(() => res.json({ changed: false }), 30000); // hold for up to 30s
const unsubscribe = onNewNotification((notification) => {
clearTimeout(timeout);
res.json({ changed: true, notification });
});
req.on("close", () => { clearTimeout(timeout); unsubscribe(); }); // client disconnected early
});
Long-polling flips the waiting: instead of the client repeatedly asking "anything new?" and getting an immediate "no" every time, the client makes ONE request, and the server holds it open, not responding at all, until either something actually happens, or a timeout is reached (30 seconds here). The moment the server responds (for either reason), the client immediately opens a new long-poll request. This eliminates almost all of short polling's wasted "nothing changed" round-trips, at the cost of the server needing to hold open many concurrent pending requests simultaneously, a real resource commitment, though a fundamentally different one than WebSockets (each is still a normal, stateless-between-calls HTTP request, just held open longer, rather than an upgraded persistent connection).
The timeout race, what most "just use long-polling" explanations skip
The subtle case: what happens when the 30-second timeout fires on the SERVER at nearly the exact moment a real event actually occurs? Both the clearTimeout callback and the onNewNotification handler are racing to call res.json(), but res.json() can only meaningfully succeed once (the response can only be sent once). A correct implementation needs the timeout handler and the event handler to be mutually exclusive, whichever fires first "wins," and the other must be a no-op (checking res.headersSent, or having already cleared the competing callback, as the code above does with clearTimeout(timeout) inside the event handler and implicitly relying on the timeout's own setTimeout callback not re-firing after it's cleared). Getting this wrong causes either a crash (attempting to send a response twice) or, worse, a silently dropped event if the timeout path "wins" a race it shouldn't have.
Try It
Predict the outcome before checking the solution.
// Client's long-poll loop:
async function pollLoop() {
try {
const res = await fetch("/api/wait", { signal: AbortSignal.timeout(5000) }); // 5s client timeout
// ... handle response
} catch (e) {
console.log("client-side timeout, retrying");
}
pollLoop();
}The SERVER holds requests open for up to 30 seconds before responding. What's the practical effect of this specific client configuration?
Solution
This client will almost never see a successful long-poll response, its own 5-second timeout fires well before the server's 30-second hold window would ever naturally resolve with real data, so nearly every request gets client-side-aborted and immediately retried, effectively degrading long-polling back into something resembling short polling (repeated requests every ~5 seconds) while still paying the OVERHEAD of holding a connection open server-side for those 5 seconds each time, arguably worse than honest short polling since the server was expecting to hold the connection much longer. This is a genuinely common real-world misconfiguration: client and server timeout values for long-polling MUST be coordinated, the client's timeout needs to be comfortably LONGER than the server's hold window (to actually receive the server's own "nothing changed" timeout response), not shorter.
Implement It Yourself
Build a minimal exponential backoff wrapper, the pattern that makes ANY polling strategy resilient to a server outage without hammering it during recovery:
async function pollWithBackoff(fetchFn, { baseDelay = 1000, maxDelay = 30000 } = {}) {
let delay = baseDelay;
while (true) {
try {
const result = await fetchFn();
delay = baseDelay; // SUCCESS resets the backoff, back to fast polling
return result;
} catch (err) {
console.log(`poll failed, retrying in ${delay}ms`);
await new Promise((resolve) => setTimeout(resolve, delay));
delay
The mechanism: on failure, the delay doubles (1s → 2s → 4s → ... capped at maxDelay) rather than retrying immediately, this is what prevents a client (or worse, thousands of clients simultaneously) from hammering an already-struggling server with immediate, synchronized retries, which can itself prevent the server from ever recovering (a self-inflicted denial-of-service pattern). Resetting the delay to baseDelay on success ensures a client returns to normal responsiveness immediately once the server recovers, rather than staying artificially slow.
Under the Hood
The debounce/throttle-adjacent timing discipline in exponential backoff mirrors the same "don't fire faster than the system can handle" principle covered in the shipped redux.saga-take-patterns work on takeLatest/debounced action handling, both are about deliberately NOT reacting to every possible trigger at maximum frequency. And the decision between short polling, long-polling, SSE, and WebSockets (covered in WebSockets & SSE) is fundamentally a tradeoff along the same axis: how much operational complexity is worth taking on in exchange for reducing wasted "nothing changed" round-trips, long-polling sits deliberately in the middle of that spectrum.
Common Mistakes
1. Mismatched client/server timeout windows
fetch("/api/wait", { signal: AbortSignal.timeout(5000) }) // client: 5s
// server holds for: 30s // ❌ client always times out firstAs shown in Try It, the client's own timeout must be comfortably longer than the server's hold window, or the client aborts before the server would ever naturally respond, degrading long-polling into worse-than-short-polling.
2. Retrying immediately on every failure with no backoff
while (true) {
try { await poll(); } catch { /* immediately loop again, no delay */ } // ❌
}An immediate, unthrottled retry loop against a failing or overloaded server actively worsens the outage, potentially thousands of clients doing this simultaneously is a self-inflicted denial-of-service pattern against your own infrastructure.
3. Not handling the timeout/event race condition server-side
setTimeout(() => res.json({ changed: false }), 30000);
onEvent(() => res.json({ changed: true })); // ❌ if BOTH fire, res.json() called twice, crashesAs covered in Concept, the timeout callback and the event callback are racing to respond exactly once, without explicit mutual exclusion (clearing the other path once one fires), this either crashes on a double-response attempt or silently drops a real event.
Best Practices
- Always coordinate client and server timeout windows explicitly, the client's timeout must exceed the server's hold duration with real margin, not just barely.
- Always implement exponential backoff (with a reset-on-success and a max-delay cap) for any polling loop, protects against a client (or a fleet of clients) worsening an already-struggling server.
- Explicitly handle the timeout-vs-event race in long-polling server code, ensure only one response path can ever actually fire per request.
- Clean up on early client disconnect (
req.on("close", ...)), an abandoned long-poll request whose resources (subscriptions, timers) aren't released is a resource leak that compounds with every client that navigates away mid-poll. - Prefer long-polling over short-polling whenever the "usually nothing changed" pattern holds, it's a meaningfully lower-overhead middle ground before committing to WebSocket/SSE operational complexity.
Performance Tips
- Short polling's cost scales linearly with (connected clients) × (poll frequency) × (request overhead) REGARDLESS of actual event frequency, long-polling's cost scales much closer to actual event frequency, since most of the "waiting" happens without a repeated request/response cycle.
- Exponential backoff's
maxDelaycap matters as much as the growth itself, an uncapped exponential delay can leave a recovered server waiting far too long before a client even attempts to reconnect; capping ensures bounded recovery-detection latency even after a long outage.
