Long polling

Sometimes a page needs to react the moment something happens on the server: a new chat message, a price update, a “your order shipped” notice. Plain HTTP is request-and-response, so the server can’t just push to you whenever it likes. Long polling is a trick that gets close to push behavior using nothing but ordinary fetch calls.

It’s the simplest way to hold a persistent connection with a server without reaching for a dedicated protocol like WebSocket or Server-Sent Events. Easy to build, and good enough for a large share of real cases.

Regular polling

Before long polling, look at the naive version: just ask, over and over.

The client sends a request on a timer — say, once every 10 seconds — that effectively says “anything new for me?” The server does two things with that request. It notes that this client is still online, and it hands back whatever messages piled up since the last check.

client
— “anything new?” →
server
← “nope” (empty)
… wait 10s …
client
— “anything new?” →
server
← “msg: hi!”
… wait 10s … (repeat forever)
Regular polling: the client asks on a fixed timer whether or not anything is waiting.

It works. But two things hurt:

  1. A message can sit on the server for up to 10 seconds before the next poll picks it up. That delay is baked into the interval.
  2. The server gets hit every 10 seconds by every client, even when nothing is happening and even when the user wandered off to another tab or fell asleep. Multiply that by many clients and it’s real load for no benefit.

For a tiny service you might not care. Past that, it needs work.

Try it below. The “client” polls once every 3 seconds, whether or not anything is waiting. Queue a message on the server at any moment and watch how long it sits until the next poll happens to come around — that wait is the latency baked into the interval.

interactiveRegular polling: the delay is the interval

Long polling

Long polling keeps the request/response shape but changes one thing: the server refuses to answer until it actually has something to say.

The flow:

  1. The client sends a request to the server.
  2. The server holds the connection open — it does not respond — while it has no message.
  3. As soon as a message appears, the server answers that pending request with it.
  4. The client receives the response and immediately fires off a fresh request.

So at almost any moment there’s a request sitting open, waiting. The connection only closes when a message is actually delivered, and then it’s reopened right away. A message reaches the client the instant the server has it, with no polling delay in between.

client
— request →
server
… holds open (pending) …
a message arrives on the server
client
← response: “msg”
server
client
— new request →
server
… holds open again …
Long polling: the request stays open (pending) until the server has a message, then a new request opens immediately.

There’s one more case to handle: the connection dropping. If the network hiccups and the request dies, the client just opens a new one right away and carries on.

clientserverpendingpendingpendingrequestreconnectnew requestconnection droppedmessage arrivesresponse
The long-polling lifecycle: each request holds open until the server has a message; a dropped connection is simply reopened at once.

Here’s a sketch of a client-side subscribe function that keeps these long requests going:

async function subscribe() {
  let response = await fetch("/subscribe");

  if (response.status == 502) {
    // Status 502 is a connection timeout error,
    // may happen when the connection was pending for too long,
    // and the remote server or a proxy closed it.
    // Let's reconnect.
    await subscribe();
  } else if (response.status != 200) {
    // An error - let's show it.
    showMessage(response.statusText);
    // Reconnect in one second.
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    // Get and show the message.
    let message = await response.text();
    showMessage(message);
    // Call subscribe() again to get the next message.
    await subscribe();
  }
}

subscribe();

The shape is a loop dressed up as recursion: subscribe fetches, waits for the response, deals with whatever came back, and then calls itself to start the next request. Three branches cover the three outcomes.

  • 502 is the timeout case. Proxies and servers often cap how long a connection may hang. When one closes a request that was pending too long, you get a 502, which here means “nothing arrived, but that’s fine” — so reconnect at once, no error shown.
  • Any other non-200 is a genuine problem. Show it, wait a second so you don’t hammer a struggling server, then reconnect.
  • 200 is the happy path. Read the message body, display it, and immediately open the next request.

Demo: a chat

Here’s the same idea running right here, with the “server” faked in memory so there’s no real network. The client keeps one request open at all times (watch the status line). The moment you send something, the pending request resolves with your message and a fresh one opens immediately — that’s the near-instant delivery long polling gives you, with no interval to wait out.

interactiveLong polling: the request stays open until a message arrives

In a real app the browser-side code lives in a file like browser.js, and serverAwaitMessage is a fetch("/subscribe") that the server leaves hanging until it has news.

When to use it

Long polling shines when messages are relatively rare. Each open request costs almost nothing while it waits, and delivery is prompt when something finally happens.

The picture changes when messages come thick and fast. Every message ends its request and starts a new one, so a steady stream turns the request/response graph into a saw-tooth of constant open-close-open-close.

rare messages — efficient
[———— pending ————]msg[———— pending ————]msg
frequent messages — saw-like, lots of overhead
[req]msg[req]msg[req]msg[req]msg[req]msg
Rare messages: mostly one quiet pending request. Frequent messages: a saw-tooth of back-to-back requests, each with full HTTP overhead.

Every one of those requests carries the full baggage of an HTTP request: headers, cookies, authentication, connection setup. When that repeats many times per second, the overhead dominates and you’re better off with a channel built for it, such as WebSocket or Server-Sent Events.