SSE vs Long Polling for Notification Feeds Permalink to this section

Part of SSE vs WebSockets vs HTTP Polling, under SSE Protocol Fundamentals & Architecture.

Notification feeds are the workload where long polling has survived longest, because the traffic is sparse and the implementation is familiar. The comparison is closer than for high-frequency feeds — and it still lands on SSE for most deployments, for reasons that have little to do with latency.

Symptom & Developer Intent Permalink to this section

  • A long-polling notification endpoint generates far more requests than notifications.
  • Notification latency is inconsistent: sometimes instant, sometimes up to a full poll interval.
  • The endpoint’s connection count is high but each connection is short-lived, thrashing the load balancer.
  • Duplicate notifications appear after a poll times out and restarts.
  • Each new client platform reimplements the poll loop slightly differently.

The intent is to decide, with numbers, whether to keep long polling or move the feed to SSE.

Root Cause Analysis Permalink to this section

Long polling holds a request open until either an event arrives or a timeout expires, then returns and the client immediately re-requests. It approximates streaming with a sequence of finite responses, and the approximation leaks in three places.

The gap long polling cannot close Timeline of a long-polling cycle showing the window between a response returning and the next request arriving, during which published events reach nobody. The gap long polling cannot close time → Request held waiting for an event 0 s Timeout returns empty response 30 s Event published nobody is listening 30.02 s Re-request the event is already past 30.05 s
The gap is small and constant, which is exactly why it is missed in testing and shows up as occasional missing notifications in production.

Request volume is a function of the timeout, not of the events. With a 30-second hold and a quiet feed, every client issues 120 requests an hour whether or not anything happened. Ten thousand clients is 1.2 million requests an hour of pure overhead, each carrying full headers and, over HTTP/1.1, potentially a new connection.

There is a gap between responses. After a response returns, the client must issue the next request. Events published in that window are missed unless the server tracks a cursor per client — which is the same replay problem SSE solves with Last-Event-ID, except you build it yourself.

Every timeout is a full request cycle. Headers, routing, authentication, and often a database check run once per poll rather than once per session. For an authenticated feed that is real CPU spent on nothing.

SSE replaces all three with one long-lived response: no repeated headers, no gap between responses, and resume built into the protocol. What it costs is a connection held open per client — the trade examined in Connection-Count Trade-offs: SSE vs WebSockets.

Step-by-Step Resolution Permalink to this section

The two transports on the properties that decide it Comparison of long polling and SSE across request volume, delivery latency, missed-event window and reconnection code. The two transports on the properties that decide it Long polling SSE Requests per client per hour 120 1 Delivery latency 0 to a full gap 0 Missed-event window between responses none while open Resume after a drop build a cursor Last-Event-ID Reconnection code your poll loop the browser's
Latency is the smaller difference. Request volume and the resume mechanism are what move the decision for most notification feeds.

Step 1 — Measure what long polling actually costs you Permalink to this section

# Requests per hour against the poll endpoint, versus notifications delivered.
curl -s localhost:9090/metrics | grep -E 'http_requests_total.*notifications|notifications_sent_total'

The ratio is the argument. A feed delivering 2 000 notifications an hour on 1.2 million polls is spending 99.8% of its request budget on “nothing yet”.

Step 2 — Compare the two on the properties that matter Permalink to this section

Property Long polling SSE
Requests per client per hour 120 at a 30 s hold 1
Delivery latency 0 ms to a full gap 0 ms
Missed-event window between responses none while connected
Resume after disconnect build a cursor yourself Last-Event-ID, built in
Reconnection logic your poll loop the browser’s
Open connections per client 1, churning 1, steady
Proxy configuration none special buffering off, timeouts up
Works without JavaScript changes yes client rewrite required

Step 3 — Serve both from one producer during the migration Permalink to this section

Keeping the poll endpoint alive means the migration is reversible.

// One notification source, two transports.
app.get('/api/notifications/poll', async (req, res) => {
  const cursor = Number(req.query.since ?? 0);
  const events = await waitForNotifications(req.user.id, cursor, { timeoutMs: 30_000 });
  res.json({ events, cursor: events.at(-1)?.id ?? cursor });
});

app.get('/api/notifications/stream', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
  });
  res.flushHeaders();
  res.write('retry: 5000\n\n');

  const cursor = req.headers['last-event-id'];
  replayFrom(req.user.id, cursor).forEach((e) => writeEvent(res, e));

  const send = (e) => writeEvent(res, e);
  subscribe(req.user.id, send);
  req.on('close', () => unsubscribe(req.user.id, send));
});

Step 4 — Switch clients behind a flag Permalink to this section

// Same handler, either transport.
function subscribeNotifications(onNotification) {
  if (!features.sseNotifications || typeof EventSource === 'undefined') {
    return startPollLoop(onNotification);          // unchanged fallback
  }
  const es = new EventSource('/api/notifications/stream', { withCredentials: true });
  es.addEventListener('notification', (e) => onNotification(JSON.parse(e.data)));
  return () => es.close();
}

Step 5 — Retire the poll loop once telemetry is clean Permalink to this section

Compare notification delivery latency and duplicate rate between cohorts for a week before removing the fallback. Keep the poll endpoint for clients that cannot hold a connection at all — some embedded and corporate environments genuinely cannot.

Validation & Monitoring Permalink to this section

Requests per hour for 10 000 clients Bar chart of hourly request volume for 10 000 clients under 30-second long polling, 60-second long polling and SSE. Requests per hour for 10 000 clients 10 000 clients, roughly 2 000 notifications per hour delivered Poll, 30 s hold 1 200 000 Poll, 60 s hold 600 000 SSE 10 000 HTTP requests per hour
Almost all of the polling volume carries no notification at all — that ratio is the whole argument, and it is measurable before any migration starts.
# 1. Request volume should collapse for the migrated cohort.
curl -s localhost:9090/metrics | grep 'notifications_poll_requests_total'

# 2. Delivery latency: publish, and time the arrival on each transport.
time curl -N -s -m 5 https://api.example.com/api/notifications/stream | head -2

Two comparisons decide whether the migration succeeded: notification delivery latency p95 by transport, and duplicate deliveries per thousand notifications. Long polling’s duplicates come from the cursor being advanced after delivery rather than atomically with it; SSE’s come from replay overlap, and are handled by client-side deduplication on the event id.

# Requests per delivered notification — the whole case, in one number.
sum(rate(notifications_poll_requests_total[1h]))
  / sum(rate(notifications_sent_total[1h]))

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Is long polling ever the better choice for notifications?

Yes, in two cases. Where clients genuinely cannot hold a long-lived connection — some embedded runtimes and heavily filtered corporate networks — and where notification volume is so low and latency tolerance so high that a five-minute poll is acceptable. Outside those, SSE removes request overhead and gives you resume for free.

Does SSE really eliminate the missed-event window?

While the connection is open, yes: there is no gap between responses because there is only one response. Across a disconnect the window returns, which is exactly what Last-Event-ID plus a replay buffer closes — and unlike a hand-rolled poll cursor, the client half of that is implemented by the browser.

Will holding a connection per user exhaust my server?

Long polling already holds one connection per user for most of the time, so the steady-state count is similar. What changes is churn: SSE holds a stable connection instead of establishing a new one every thirty seconds, which reduces load balancer and TLS handshake work.

How do I avoid duplicate notifications after reconnect?

Deduplicate on the event id in the client. Replay is deliberately inclusive at the boundary — the server would rather resend than lose — so a small set of recently-seen ids in the client is the standard companion to any resume mechanism.