Long polling test

Time one request with curl -s -w '%{time_total}' http://127.0.0.1:8733/poll against an endpoint that holds the request open. It answered after 1.229 seconds, the moment data arrived, and returned 204 at 5.003 seconds when nothing did. A client retrying instantly on that 204 sent 239 requests in three seconds.

Why check this

Long polling is an endpoint that answers late on purpose, so every timeout between client and server is part of the contract. Run this when a notification, queue or chat feature ships on plain HTTP, and after a load balancer or client library changes.

The failure it prevents is a poller that becomes a load generator. The server answers an empty poll at once instead of holding it, the client reconnects with no delay, and one tab produces hundreds of requests a second. The feature still works, which is why nobody notices it.

Prerequisites

import { createServer } from 'node:http';

const at = () => new Date().toISOString().slice(11, 23);
const waiters = [];
let seq = 0;
let reqs = 0;

createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:8733');
  if (url.pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<!doctype html><meta charset="utf-8"><title>long poll local</title>');
    return;
  }
  const n = ++reqs;
  console.log(`${at()}  req  #${n} ${url.pathname}`);

  if (url.pathname === '/push') {
    seq += 1;
    const msg = { id: seq, text: url.searchParams.get('text') ?? 'tick' };
    while (waiters.length) waiters.shift()(msg);
    res.writeHead(204).end();
    return;
  }

  if (url.pathname === '/shortpoll') {
    res.writeHead(204).end();
    console.log(`${at()}  res  #${n} 204 nothing yet`);
    return;
  }

  if (url.pathname === '/poll') {
    const deliver = (msg) => {
      clearTimeout(timer);
      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
      res.end(JSON.stringify(msg));
      console.log(`${at()}  res  #${n} 200 id=${msg.id}`);
    };
    const drop = () => {
      const i = waiters.indexOf(deliver);
      if (i >= 0) waiters.splice(i, 1);
    };
    const timer = setTimeout(() => {
      drop();
      res.writeHead(204).end();
      console.log(`${at()}  res  #${n} 204 held 5000 ms, nothing arrived`);
    }, 5000);
    waiters.push(deliver);
    req.on('close', () => { clearTimeout(timer); drop(); });
    return;
  }

  res.writeHead(404).end();
}).listen(8733, '127.0.0.1', () => console.log(`${at()}  listening on 127.0.0.1:8733`));
const target = process.argv[2];
const start = Date.now();
let n = 0;
while (Date.now() - start < 3000) {
  const r = await fetch(target);
  if (r.status === 200) await r.json();
  else await r.arrayBuffer();
  n += 1;
}
console.log(`${n} requests in ${((Date.now() - start) / 1000).toFixed(1)} s`);

Steps

  1. Step 1.

    Open a poll, then push a message into it 1.2 seconds later.

    ( sleep 1.2; curl -s -o /dev/null "http://127.0.0.1:8733/push?text=order-4711" ) >/dev/null 2>&1 & curl -s -w '\nhttp %{http_code}  time_starttransfer %{time_starttransfer}  time_total %{time_total}\n' http://127.0.0.1:8733/poll
    
    {"id":1,"text":"order-4711"}
    http 200  time_starttransfer 1.229341  time_total 1.229378

    time_starttransfer of 1.229 seconds is the point of the transport. The server held the request and sent no byte until there was something to say. A 200 in 5 ms would not be a long poll.

  2. Step 2.

    Poll again with nothing to deliver, and let the server decide when to answer.

    curl -s -w '\nhttp %{http_code}  time_total %{time_total}\n' http://127.0.0.1:8733/poll
    
    
    http 204  time_total 5.002894

    Empty body, 204, after 5.003 seconds: the server's own hold expiring. Every long-polling endpoint has this number, and the client timeout has to be larger than it.

  3. Step 3.

    Set a client timeout shorter than the hold and watch which side reports the failure.

    curl -s --max-time 2 -w '\nhttp %{http_code}  time_total %{time_total}\n' http://127.0.0.1:8733/poll; echo "curl exit=$?"
    
    
    http 000  time_total 2.012628
    curl exit=28

    Status 000 means nothing was received. Exit 28 is CURLE_OPERATION_TIMEDOUT. Now read the server terminal for the three steps so far:

    07:35:31.609  listening on 127.0.0.1:8733
    07:35:33.613  req  #1 /poll
    07:35:34.837  req  #2 /push
    07:35:34.839  res  #1 200 id=1
    07:35:34.873  req  #3 /poll
    07:35:39.875  res  #3 204 held 5000 ms, nothing arrived
    07:35:39.910  req  #4 /poll

    Request #4 is the abandoned one and has no res line under it. The client called it a failure and the server logged no status and no duration, so a dashboard built on server-side response codes cannot see this failure.

  4. Step 4.

    Run the loop client against /shortpoll, the route that answers an empty poll at once.

    node poll-client.mjs http://127.0.0.1:8733/shortpoll
    
    239 requests in 3.0 s

    The server terminal for those seconds, six lines of 478:

    07:36:04.564  req  #7 /shortpoll
    07:36:04.565  res  #7 204 nothing yet
    07:36:04.572  req  #8 /shortpoll
    07:36:04.572  res  #8 204 nothing yet
    07:36:04.576  req  #9 /shortpoll
    07:36:04.576  res  #9 204 nothing yet

    Eight milliseconds between requests, then four. Client and endpoint are each correct, and together they are a loop with no brake.

  5. Step 5.

    Run the same client, unchanged, against /poll, the route that holds.

    node poll-client.mjs http://127.0.0.1:8733/poll
    
    1 requests in 5.0 s

    The server terminal for the same period:

    07:36:07.717  req  #246 /poll
    07:36:12.722  res  #246 204 held 5000 ms, nothing arrived

    One request against 239, same client, same machine, comparable window. The whole difference is whether the server holds the request.

  6. Step 6.

    Open http://127.0.0.1:8733/ in Chrome, open DevTools, Network tab, and run one poll from the Console.

    fetch('/poll').then((r) => r.status);
    
    8733  200  document  http://127.0.0.1:8733/
    8733  204  fetch  http://127.0.0.1:8733/poll
    8731  200  document  http://127.0.0.1:8731/
    8731  200  eventsource  http://127.0.0.1:8731/sse

    Port, status, resource type, URL, read through the DevTools protocol; the Network panel shows the same word in its Type column. The long poll is a fetch that stays pending for five seconds. The last two lines are a server-sent stream on another port in the same capture, typed eventsource.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | time_starttransfer close to the wait | The server is holding the request | Working as intended. Record the hold length. | | A 200 or 204 within milliseconds, repeatedly | The server answers empty polls at once | This is a busy loop waiting to happen. Hold the request or make the client back off. | | 204 exactly at the hold length | The server timeout fired | Confirm the client timeout is larger. | | curl exit 28, status 000 | The client gave up first | Nothing is logged on the server. Raise the client timeout above the hold. | | Requests milliseconds apart in the server log | The brake is missing on both sides | Compare the request count, as in steps 4 and 5. |

Common mistakes

Sign: The client timeout is set to a comfortable-looking value such as two seconds.Cause: A long poll is meant to take longer than a normal request. With a 5000 ms hold and a 2000 ms client timeout, every poll ends in curl exit 28, the server writes no response line, and the error rate on its dashboard stays at zero while the feature delivers nothing. The client timeout has to exceed the server hold.
Sign: The feature works in review and the request count in production is enormous.Cause: A server that answers an empty poll immediately turns a correct client into a load generator. The same client made 239 requests in 3.0 s against /shortpoll and 1 request in 5.0 s against /poll. Neither run logged an error, so this is invisible to any check that reads status codes instead of counting requests.

What to check next

Held requests occupy connections, so How to test API concurrency belongs on the same endpoint.

FAQ

How do I test a long polling endpoint with curl?

Time one request. curl -s -w '%{time_total}' <the url> prints the seconds it took, and time_starttransfer shows when the first byte arrived. A held poll answers when data arrives, or when its own timeout fires.

Long polling vs websocket: how do I tell which one a client is using?

Count requests in the server log. Long polling makes one HTTP request per message, so the log grows with traffic. A WebSocket appears as one upgrade request and then goes quiet. The browser side is in How to check websocket connection in chrome.

Long polling vs SSE: how do they differ in a network log?

By resource type and by completion. The capture in step 6 typed the poll as fetch and the stream as eventsource. The poll completes with a status and is then repeated; the stream stays pending with a body that keeps growing.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny