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
- Node 22. Save the server below as
poll-server.mjsand start it withnode poll-server.mjs. It listens on127.0.0.1:8733:/pollholds a request for up to 5000 ms,/shortpollanswers 204 at once,/pushdelivers a message to whoever is waiting. - The server prints one line per request and one per response. Keep that terminal, because those timestamps are the evidence in steps 3 to 5.
- The ten-line client below, saved as
poll-client.mjs. It polls in a loop with no delay, which is the behaviour under test. - curl with
-w, and Chrome or another Chromium browser for the last step. - Everything here is loopback. The 1.229 seconds in step 1 is the server's own wait with no network in it, and the 239 requests in step 4 is one process on one laptop, not a capacity figure.
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
- 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.229378time_starttransferof 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. - 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/pollhttp 204 time_total 5.002894Empty 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.
- 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=28Status 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 /pollRequest #4 is the abandoned one and has no
resline 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. - 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/shortpoll239 requests in 3.0 sThe 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 yetEight milliseconds between requests, then four. Client and endpoint are each correct, and together they are a loop with no brake.
- Step 5.
Run the same client, unchanged, against
/poll, the route that holds.node poll-client.mjs http://127.0.0.1:8733/poll1 requests in 5.0 sThe server terminal for the same period:
07:36:07.717 req #246 /poll 07:36:12.722 res #246 204 held 5000 ms, nothing arrivedOne request against 239, same client, same machine, comparable window. The whole difference is whether the server holds the request.
- 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/ssePort, 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
fetchthat stays pending for five seconds. The last two lines are a server-sent stream on another port in the same capture, typedeventsource.
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
What to check next
- How to test server sent events: one held request that keeps delivering, instead of one request per message.
- Eventsource auto reconnect: what a stream does about a drop, a question long polling answers by design.
- How to test websocket connection: the transport to compare against when the poll rate stops being acceptable.
- How to test API timeout handling: the same client and server timeout question on an endpoint that is not meant to be slow.
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.
Related on this site
intermediate9 minpublished updated Maks Verny