Server-sent events, polling and long polling: what they are and when to use
Polling, long polling, and Server-Sent Events (SSE) are three HTTP-based techniques for getting server updates to the browser, each trading off request frequency, connection overhead, and latency differently.
Theory
TL;DR
- Polling asks the server "anything new?" on a fixed timer, even if nothing changed
- Long polling holds the request open until the server has data, then restarts immediately
- SSE keeps one connection open permanently and the server pushes events whenever it wants
- Analogy: polling is a kid checking the mailbox every 5 minutes; long polling waits at the mailbox until mail arrives; SSE is the mail carrier calling you over one open phone line
- Decision rule: infrequent checks → polling; low-latency without WebSocket → long polling; frequent one-way streams → SSE
Quick example
// Polling: fires every 3s, even when empty
setInterval(() => fetch('/data').then(r => r.json()).then(console.log), 3000);
// Long polling: one request blocks until data or timeout
async function longPoll() {
const res = await fetch('/long-data'); // server holds ~30s
console.log(await res.json()); // prints once per data/timeout
longPoll(); // restart immediately
}
longPoll();
// SSE: single connection, browser auto-reconnects
const es = new EventSource('/events');
es.onmessage = e => console.log(e.data); // streams events as they arrivePolling logs repeatedly regardless of new data. Long polling logs once per cycle. SSE logs each event the moment the server sends it.
Key difference
Polling fires a new HTTP request on every interval whether or not anything changed. Long polling fires one request, which the server holds until data is ready (or a timeout fires), then the client immediately opens another. SSE opens one HTTP connection with Content-Type: text/event-stream and keeps it open indefinitely. The server writes data: ...\n\n chunks down that single socket whenever it wants, no repeated handshakes.
When to use
- Dashboard refresh every 30+ seconds: polling works fine, simple to implement
- Chat in environments without WebSocket or SSE support: long polling keeps latency low
- Live notifications, stock tickers, log streaming: SSE is the right fit
- Bidirectional communication (games, collaborative editors): skip all three, use WebSockets
- Mobile or battery-sensitive clients: avoid long polling and SSE because kept connections drain battery
Comparison table
| Feature | Polling | Long Polling | SSE |
|---|---|---|---|
| Request pattern | Many short requests periodically | Few long-held requests | Single persistent connection |
| Latency | High (waits for next poll interval) | Low (fires on data) | Lowest (instant push) |
| Server load | High (empty responses) | Medium (held connections) | Low (one connection per client) |
| Scalability | Poor (request volume) | Poor (connection limits) | Good (up to ~10k conns/server) |
| Browser support | All | All | 90%+ (IE needs polyfill) |
| Complexity | Low | Medium (reconnect logic) | Low (native EventSource API) |
| When to use | Rare batch checks (cron-style dashboards) | Legacy real-time, old browser support | Live feeds, notifications, log streams |
How it works internally
For polling and long polling, the browser uses fetch or XMLHttpRequest. In Node.js/Express, long polling works by holding the res object in memory and calling res.end(data) only when data is available or a setTimeout fires.
SSE uses HTTP/1.1 chunked transfer encoding. The browser's EventSource API reads lines from the stream looking for the data: prefix followed by two newlines (\n\n). If the connection drops, EventSource auto-reconnects after 3 seconds by default. In Node, you must call res.flushHeaders() (Node 18+) to prevent buffering, otherwise the client sees nothing until the connection closes.
HTTP/2 changes one detail for SSE: instead of the 6-connection-per-domain limit of HTTP/1.1, streams multiplex over one TCP connection, so that cap largely disappears.
Common mistakes
1. No error handler on EventSource
// Wrong: connection dies with no signal to the UI
const es = new EventSource('/events');
es.onmessage = e => console.log(e.data);
// Correct: handle reconnection explicitly
es.onerror = () => console.log('SSE connection lost, browser will retry...');The browser retries 3 times automatically, then stops. Without onerror, your UI freezes with no indication.
2. No timeout on long poll requests
// Wrong: hangs forever if server crashes
const res = await fetch('/long-data');
// Correct: abort after 35s (server should close at 30s)
const controller = new AbortController();
setTimeout(() => controller.abort(), 35000);
const res = await fetch('/long-data', { signal: controller.signal });3. Polling too aggressively
// Wrong: 1s interval = 86,400 requests/day per client
setInterval(() => fetch('/stock'), 1000);
// Correct: start at 5s, apply exponential backoff under load4. Forgetting flushHeaders in Node 18+
// Without this, Node buffers the response and client sees nothing
res.flushHeaders();5. Using SSE for bidirectional data
Sending client data through SSE query params is not what the protocol is designed for and corrupts the stream. If you need both directions, use WebSockets.
Real-world usage
- GitHub: SSE for live notifications in the web interface
- Twitter/X: SSE for streaming tweet timelines in the web app
- Socket.io v4: falls back to long polling when WebSocket upgrade fails
- React Query and SWR: polling for background cache invalidation (default 5 minutes)
- Firebase Realtime Database: long polling polyfill for older browsers
Follow-up questions
Q: How does SSE differ from WebSockets at the protocol level?
A: SSE runs over regular HTTP/1.1 with chunked transfer encoding and is unidirectional (server to client only). WebSockets perform an HTTP upgrade handshake, then switch to a raw TCP connection that is bidirectional with no HTTP overhead per message.
Q: What is the browser connection limit for SSE with HTTP/1.1?
A: About 6 connections per domain, the same as any HTTP/1.1 connection. With HTTP/2 streams multiplex over one TCP connection, so this limit is mostly a concern for older setups.
Q: Why not always use SSE over polling?
A: SSE is unidirectional. Some firewalls and proxies close long-lived connections. SSE also does not support binary data natively. For simple infrequent checks like a dashboard refresh every 30 seconds, polling is simpler and works everywhere.
Q: Latency numbers for each?
A: Polling latency equals roughly half the poll interval (on average you wait half a cycle). Long polling adds about 100ms of overhead on top of the hold time. SSE pushes in under 50ms once the connection is established.
Q: Production scenario: 10,000 users on long polling spike your Node.js CPU to 100%. How do you fix it without switching to WebSockets?
A: Long polling with 10,000 users means 10,000 active timers in the Node event loop. It overwhelms it. The fix is Redis pub/sub: instead of each request holding its own timer, it subscribes to a Redis channel. When new data arrives, one publish call notifies all waiting responses simultaneously. Switching to SSE also helps because one persistent connection per user scales better than 10,000 repeated request cycles.
Examples
Polling: basic timer-based fetch
// Checks for updates every 5 seconds
// Cache-busting prevents stale responses
function startPolling(url, interval = 5000) {
setInterval(async () => {
const res = await fetch(`${url}?_t=${Date.now()}`);
const data = await res.json();
console.log('Latest data:', data);
}, interval);
}
startPolling('/api/dashboard');Simple to write, but every request hits the server whether or not data changed. At 1,000 users and a 5-second interval that is 12,000 requests per minute, most returning empty responses.
SSE: Express server with React client
This is close to how GitHub delivers live notifications.
Server (Express.js):
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.flushHeaders(); // required in Node 18+
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ msg: 'New notification', ts: Date.now() })}\n\n`);
}, 5000);
// Clean up when client disconnects
req.on('close', () => clearInterval(interval));
});React client:
function Notifications() {
const [msgs, setMsgs] = useState([]);
useEffect(() => {
const es = new EventSource('/events');
es.onmessage = e => setMsgs(m => [...m, JSON.parse(e.data)]);
es.onerror = () => console.log('Connection lost, retrying...');
return () => es.close(); // cleanup on unmount
}, []);
return <ul>{msgs.map((m, i) => <li key={i}>{m.msg}</li>)}</ul>;
}The list grows in real time with no page refresh. Each message appears within milliseconds of the server writing it. The req.on('close') cleanup is easy to forget and causes a memory leak when clients disconnect.
Long polling with retry and timeout
Production code needs to survive network failures and server restarts.
async function robustLongPoll(url, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 35000);
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
clearTimeout(timeout);
console.log('Received:', data);
return data;
} catch (err) {
clearTimeout(timeout);
console.log(`Attempt ${attempt + 1} failed: ${err.message}`);
// Wait longer between each retry
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
console.log('Falling back to polling');
}
// Restart after each successful response
async function keepPolling(url) {
while (true) {
await robustLongPoll(url);
}
}The 35-second abort is intentionally longer than the server's 30-second hold. Normal server timeouts complete cleanly. A real crash triggers the AbortController and the exponential backoff kicks in.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.