QA How-To
Testing long polling APIs (2026)
Learn testing long polling APIs with deterministic event timing, timeout layers, cancellation, cursors, concurrency, retries, and runnable Node.js tests.
20 min read | 3,038 words
TL;DR
Testing long polling APIs requires deterministic control of both sides of the wait. Verify immediate delivery, delayed delivery, empty timeout, cancellation, cursor resume, duplicate prevention, authorization changes, and cleanup, then exercise real proxy timeout and concurrent reconnect behavior.
Key Takeaways
- Treat event delivery, server wait expiry, client cancellation, and infrastructure timeout as different outcomes.
- Use a cursor or version token to prove reconnects do not lose or duplicate events.
- Control event publication with test hooks instead of relying on arbitrary sleep durations.
- Set timeout layers deliberately so the application finishes before gateways and clients give up.
- Verify disconnected waiters are removed and do not consume resources or receive later events.
- Load tests must model held connections, reconnect jitter, slow consumers, and event fan-out.
Testing long polling APIs means proving correct behavior while an HTTP request remains open waiting for data. A reliable suite distinguishes an event response from an intentional empty timeout, a client cancellation, an infrastructure disconnect, and a server failure. It also verifies that reconnecting with a cursor neither loses nor duplicates events.
Long polling looks simple because it uses ordinary HTTP requests, but time becomes part of the contract. Client, application, gateway, load balancer, and socket timeouts can compete. A weak test waits for 'something' and passes on any 2xx response. A strong test controls event publication, bounds every wait, checks the exact response semantics, and proves server resources are cleaned up.
This guide uses a cursor-based event endpoint. The same approach applies to job status updates, notification feeds, device commands, and change streams delivered over repeated HTTP requests. Review API error handling and negative testing alongside it, especially when retry and overload responses are involved.
TL;DR
| Scenario | Expected observable result | Important follow-up |
|---|---|---|
| Event already available | Immediate 200 with event and next cursor |
No unnecessary hold |
| Event arrives during wait | Delayed 200 before application wait expires |
Correct event order |
| No event | Documented empty response at server wait limit | Cursor remains usable |
| Client cancels | Client sees cancellation | Server removes waiter |
| Gateway closes first | Transport or gateway-specific failure | Timeout configuration defect |
| Reconnect with cursor | Only events after cursor | No loss or duplicates |
| Overload | Documented rejection or backoff signal | Reconnect uses jitter |
Use synchronization and observable server state in component tests. Reserve wall-clock and proxy assertions for integration environments where those layers actually exist.
1. Testing long polling APIs starts with an explicit contract
A long poll is an HTTP request that the server may hold until data becomes available or an application-defined wait expires. The client processes the response and immediately issues another request, usually carrying a cursor, version, timestamp, ETag, or last event ID. Long polling is not the same as repeatedly sending short-interval requests. It intentionally reduces empty responses by letting the server wait.
Document five items before automating: request parameters, event response, empty response, cursor semantics, and error behavior. For example, GET /events?after=41&waitMs=25000 might return 200 with events after cursor 41, or 204 No Content when no event appears within the requested wait. Another valid API might always return 200 with an empty array. The test must enforce the product contract, not a universal convention.
Define whether one response can contain multiple events, the order guarantee, maximum batch size, cursor scope, and retention window. Decide whether the cursor identifies the last delivered event, last acknowledged event, or a snapshot boundary. State whether an expired cursor returns a reset instruction, a gap error, or a new baseline.
Also define cancellation and overload. A client closing the connection should release its waiter promptly. If concurrent waiters exceed a limit, the server might reject new requests with a documented status and retry guidance. Avoid making tests accept any timeout-like outcome, because 204, 504, 408, AbortError, and a socket reset identify different layers and lead to different client behavior.
2. Map the timeout hierarchy before writing tests
Long polling succeeds only when timeout layers are ordered intentionally. The application wait should normally finish before the proxy's idle or request timeout, and the client budget should allow the expected application response plus network margin. Exact values depend on the platform. Use the configured values as test inputs rather than copying an arbitrary duration from another system.
| Layer | Purpose | Failure when it expires first |
|---|---|---|
| Application wait | Ends an empty poll normally | Expected empty response |
| Gateway or load balancer | Protects infrastructure | 504, reset, or truncated response |
| HTTP client request budget | Bounds caller wait | Client-side timeout or abort |
| Test runner timeout | Prevents a stuck test process | Generic test failure with weak diagnosis |
For an illustrative configuration, an application may wait 20 seconds, the gateway may permit 30 seconds, and the client may allow 35 seconds. Those are not recommendations. The invariant is ordering plus enough operational margin. Test the configured relationship directly and include the relevant values in failure output.
Distinguish idle timeout from total request duration. Some gateways reset an idle timer when bytes flow; others impose a hard maximum. Long polling responses usually send no body until completion, so periodic bytes are not necessarily part of the protocol. Do not add heartbeats just to satisfy a proxy without defining how clients parse them.
Unit tests should use small injected durations to run quickly. One deployed test should use production-like gateway configuration and verify the application, not the proxy, owns the normal empty completion. The test-runner timeout must be longer than the client budget so the client produces the actionable error first.
3. Build a deterministic event and waiter fixture
The following runnable Node.js fixture implements a minimal cursor-based long poll. It is intentionally in memory so tests can publish an event at an exact state and inspect active waiters. Save it as long-poll.test.mjs and run node --test long-poll.test.mjs.
import http from 'node:http';
import test from 'node:test';
import assert from 'node:assert/strict';
function createLongPollServer() {
const events = [];
const waiters = new Set();
function availableAfter(cursor) {
return events.filter((event) => event.id > cursor);
}
function complete(waiter, status, body) {
if (waiter.response.writableEnded || waiter.response.destroyed) return;
clearTimeout(waiter.timer);
waiters.delete(waiter);
if (body) {
waiter.response.writeHead(status, { 'content-type': 'application/json' });
waiter.response.end(JSON.stringify(body));
} else {
waiter.response.writeHead(status, { 'x-next-cursor': String(waiter.cursor) });
waiter.response.end();
}
}
const server = http.createServer((request, response) => {
const url = new URL(request.url, 'http://fixture.test');
if (request.method !== 'GET' || url.pathname !== '/events') {
response.writeHead(404).end();
return;
}
const cursor = Number(url.searchParams.get('after') ?? '0');
const waitMs = Number(url.searchParams.get('waitMs') ?? '100');
if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isFinite(waitMs) || waitMs < 0) {
response.writeHead(400, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid request' }));
return;
}
const ready = availableAfter(cursor);
if (ready.length > 0) {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ events: ready, nextCursor: ready.at(-1).id }));
return;
}
const waiter = { cursor, response, timer: undefined };
waiter.timer = setTimeout(() => complete(waiter, 204), waitMs);
waiters.add(waiter);
response.on('close', () => {
clearTimeout(waiter.timer);
waiters.delete(waiter);
});
});
function publish(value) {
const event = { id: events.length + 1, value };
events.push(event);
for (const waiter of [...waiters]) {
const ready = availableAfter(waiter.cursor);
if (ready.length > 0) {
complete(waiter, 200, { events: ready, nextCursor: ready.at(-1).id });
}
}
return event;
}
return { server, publish, activeWaiters: () => waiters.size };
}
The fixture does not pretend to be a production broker. It gives the tests deterministic control over event order, wait state, and cleanup. Production code should use durable storage or messaging semantics appropriate to its delivery guarantee. A local fixture can still expose application mistakes in cursor filtering and disconnect handling before a real dependency is involved.
4. Test immediate, delayed, and empty responses
Add these tests below the fixture. The server binds to port zero, which lets the operating system choose a free port. Each test owns startup and teardown.
async function startFixture(t) {
const fixture = createLongPollServer();
await new Promise((resolve) => fixture.server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => fixture.server.close(resolve)));
const { port } = fixture.server.address();
return { fixture, baseUrl: `http://127.0.0.1:${port}` };
}
test('returns an event that already exists without waiting', async (t) => {
const { fixture, baseUrl } = await startFixture(t);
fixture.publish('ready');
const response = await fetch(`${baseUrl}/events?after=0&waitMs=500`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
events: [{ id: 1, value: 'ready' }],
nextCursor: 1
});
assert.equal(fixture.activeWaiters(), 0);
});
test('releases a held request when an event arrives', async (t) => {
const { fixture, baseUrl } = await startFixture(t);
const pending = fetch(`${baseUrl}/events?after=0&waitMs=500`);
await waitUntil(() => fixture.activeWaiters() === 1);
assert.equal(fixture.activeWaiters(), 1);
fixture.publish('changed');
const response = await pending;
assert.equal(response.status, 200);
assert.equal((await response.json()).events[0].value, 'changed');
assert.equal(fixture.activeWaiters(), 0);
});
test('returns the documented empty response at wait expiry', async (t) => {
const { fixture, baseUrl } = await startFixture(t);
const response = await fetch(`${baseUrl}/events?after=7&waitMs=30`);
assert.equal(response.status, 204);
assert.equal(response.headers.get('x-next-cursor'), '7');
assert.equal(await response.text(), '');
});
The 20 ms coordination delay keeps the example compact, but a production component suite should expose a hook or condition that confirms waiter registration. Then publishing can occur after the known state rather than after a guessed sleep. Timing assertions should use broad upper bounds only when latency is the behavior under test.
For a delayed response, assert it did not complete before publication and did complete afterward. For an empty response, assert the application-defined status, body, headers, and cursor. A 204 response must not contain a body. If the API uses 200 with an empty array, assert that exact representation instead.
5. Test client cancellation and server cleanup
Cancellation is successful only when both sides behave correctly. The client should receive its documented abort signal, and the server should remove the waiter, stop timers or dependency subscriptions, and avoid delivering a later event to the abandoned response. Use AbortController with platform fetch for a direct test.
async function waitUntil(predicate, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('condition was not met');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
test('removes a waiter when the client aborts', async (t) => {
const { fixture, baseUrl } = await startFixture(t);
const controller = new AbortController();
const pending = fetch(`${baseUrl}/events?after=0&waitMs=1000`, {
signal: controller.signal
});
await waitUntil(() => fixture.activeWaiters() === 1);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
await waitUntil(() => fixture.activeWaiters() === 0);
fixture.publish('must not target an abandoned waiter');
assert.equal(fixture.activeWaiters(), 0);
});
At integration level, instrument active requests, broker subscriptions, and timers. Compare them before the request, while held, and after cancellation. The count should return according to the service's cleanup expectations. Also cancel during event serialization and immediately after the response starts, because disconnect races are common.
A client timeout implemented through abort is still a client decision. Do not label it a server timeout. Capture whether the server completed normally, noticed a closed connection, or continued expensive work. This distinction matters for capacity and billing.
Test mobile or unstable-network behavior by terminating the real connection through a proxy or network fault tool. A component-level AbortController validates application cancellation, while a connection reset validates transport cleanup. Keep the cases separate so failures remain diagnosable.
6. Verify cursor resume, ordering, duplicates, and retention gaps
A cursor is the core reliability mechanism for repeated polls. Publish a known sequence, consume a response, reconnect using its returned cursor, publish more events, and assert only later IDs arrive. Repeat a request with the same cursor to characterize at-least-once or replay behavior. If the API requires acknowledgment separately, do not treat delivery as acknowledgment.
Test these invariants:
- Events within a response follow the documented order.
- The next cursor corresponds to the response according to the contract.
- Reconnecting with that cursor does not skip a later event.
- Reusing an older cursor has documented replay behavior.
- A future, malformed, cross-user, or cross-topic cursor is rejected safely.
- An expired cursor produces a recognizable gap or reset result.
Do not decode an opaque cursor in the test. Treat it as an exact token returned by the server. If cursors are scoped to user, tenant, filter, or subscription, try one in another scope and expect rejection. This prevents data leakage and silent query changes.
Retention deserves a deliberate scenario. Seed events beyond the retention boundary, request with an old cursor, and assert the API's recovery contract. Returning only the newest events without signaling a gap can make a client believe it has a complete history. If the product offers a snapshot endpoint, verify the reset link or token leads to a consistent baseline before polling resumes.
Use unique event IDs to detect duplicates across response boundaries. Payload equality is not enough because two legitimate events can contain the same value.
7. Cover validation, authentication, authorization, and state changes
Validate wait, cursor, topic, filter, and batch parameters at boundaries. Cover missing values, negative waits, values above the allowed maximum, malformed tokens, repeated parameters, and unsupported filters. The server should cap or reject waits according to the contract, not hold a connection indefinitely. Error responses should be immediate and should not allocate a waiter.
Authentication can expire while a request is held. Define whether authorization is evaluated only at request start or also before event delivery. Then test that rule using an injected token validator or controllable identity provider. If a user loses permission during the wait, delivering a newly protected event may be a data leak. If the server closes the poll, assert the stable error response and client recovery behavior.
Verify tenant and topic isolation under concurrency. Hold polls for two users, publish an event for one, and confirm only the authorized waiter completes. The other request should remain held or finish normally without the event. Repeat with similarly named topics and guessed cursors.
State-changing events can race with client reconnect. Create the state transition and event in the same transaction or documented consistency boundary, then assert the client can retrieve the referenced state after receiving the event. If eventual consistency is intended, use polling with a bounded deadline and report the observed lag rather than inserting a fixed sleep.
Keep authorization headers and cursor tokens out of failure logs when they encode sensitive data. Log a hash or test correlation ID instead.
8. Test retry, backoff, overload, and reconnect storms
A healthy long-poll client reconnects immediately after a normal event or empty completion, but backs off after transport and server failures. If every client reconnects at the same fixed delay, a brief outage can produce a synchronized storm. Test exponential or policy-defined backoff with jitter by injecting a clock and deterministic random source into the client.
Classify responses before asserting retry. A normal 204 usually starts the next poll without error backoff. A 429 should follow rate-limit guidance. A transient 503 may be retryable. A 401 may require token refresh, while 403 is not solved by repeated requests. A malformed cursor may require reset, not generic retry. The API rate limiting testing guide provides deeper quota and header cases.
Test a series such as transient failure -> failure -> success and assert delay bounds, attempt count, cursor preservation, and cancellation during backoff. Never use real multi-second waits in a unit test. Advance a fake clock or make the delay function injectable.
For overload, open waiters to the configured limit and attempt one more. Assert the exact rejection, that existing waiters remain healthy, and that capacity returns when a client cancels. If the service queues excess waiters, verify the queue is bounded and fair.
After an outage, simulate many clients with distinct deterministic jitter seeds. Inspect the reconnect distribution and server concurrency, not only aggregate success. The acceptance criteria should come from capacity planning and service objectives, not a universal request count.
9. Measure concurrency, capacity, and observability
Long polling capacity is driven by concurrent held connections, waiter memory, broker subscriptions, file descriptors, event fan-out, and reconnect rate. Requests per second alone can be misleading because a healthy system may hold many requests while completing relatively few. Measure active polls, completion reason, hold duration, events per response, reconnects, cancellations, timeouts by layer, and cleanup latency.
A load model should contain several populations: clients with no events, clients receiving occasional events, a burst delivered to many clients, disconnected clients, and slow clients. Use realistic authentication and proxy routes. Ramp concurrency gradually, hold it long enough to observe resource trends, cancel a fraction, and verify counts return toward baseline.
Check fairness. One busy topic should not starve a quiet tenant. A fan-out event should reach every intended waiter exactly according to delivery semantics. If the service batches events, assert maximum batch and continuation cursor behavior under burst.
Instrument completion reason as application event, application empty timeout, client disconnect, authorization change, overload rejection, dependency error, or infrastructure termination. If every exit is logged as 'timeout,' diagnosis will be slow. Correlate client request IDs with server waiter IDs while redacting credentials.
General load-design practices from performance testing with k6 scripts still apply, but confirm the chosen tool truly holds and cancels concurrent HTTP requests the way production clients do. Validate a small script against server metrics before trusting a large run.
10. Scale testing long polling APIs in CI
Keep deterministic component tests on every commit. They should cover available data, delayed release, empty expiry, cancellation, validation, cursor resume, actor isolation, and injected dependency failure in seconds. Use small configurable waits and observable fixture states.
Add an integration suite through the actual HTTP server and event broker. It should validate connection closure, authentication middleware, cursor persistence, and one real cancellation race. A deployed smoke test should cross the ingress or gateway and prove the normal application wait ends before infrastructure timeout. Run broader concurrency and outage recovery in a scheduled performance environment.
Make failures self-explanatory. Record the cursor fingerprint, event IDs, completion reason, configured timeouts, elapsed time, active waiter count, response status, and request correlation ID. Avoid asserting exact millisecond timing unless testing a hard minimum or maximum. Prefer event ordering assertions such as 'request remained pending until publication, then completed within the test budget.'
Tests must clean up held requests before fixture shutdown. Track controllers or client sockets and cancel them in teardown. Confirm server waiter count reaches zero, then close the server. This prevents one failed assertion from hanging the suite.
Avoid test-runner retries as a cure for timing defects. A rerun can hide resource leaks and cursor bugs. Retain the first attempt's metrics and fix the coordination or product behavior. Use the API test data management guide to isolate users, topics, and event streams across parallel CI workers.
Interview Questions and Answers
Q: How is long polling different from regular polling?
Regular polling returns immediately, often with no change, and the client waits before trying again. Long polling lets the server hold a request until data appears or an application wait expires, then the client reconnects. Tests must therefore validate held-request lifecycle and timeout ordering.
Q: What is the first test you would automate?
I would issue a poll with no available data, confirm the server registered the waiter, publish one known event, and assert the request completes with that event and a usable next cursor. This single case validates the central coordination path without relying on a long sleep.
Q: How do you test that no events are lost during reconnect?
I publish uniquely identified events around response and reconnect boundaries, always passing the server-returned cursor. I collect IDs across responses and assert the expected ordered set with no prohibited gaps or duplicates. I also cover cursor reuse and expiry according to the delivery contract.
Q: Which timeout should be shortest?
Normally the application wait should complete before the gateway or load balancer ends the request, and the client budget should allow that normal completion. The test-runner timeout should be last. Exact margins come from the configured environment and service objectives.
Q: How do you test client disconnect cleanup?
I wait until a server metric or fixture hook confirms registration, abort or reset the client connection, and assert active waiters and subscriptions return to baseline. I then publish an event to prove the abandoned response is not used.
Q: What should a long polling load test measure?
It should measure concurrent held polls, event and empty completions, hold duration, reconnect rate, cancellation cleanup, resource use, fan-out latency, and errors by layer. Requests per second is insufficient because held concurrency is the dominant behavior.
Q: How should clients retry after failures?
They should classify the outcome first. Normal empty completions can repoll, transient failures use bounded backoff and jitter, rate limits follow server guidance, authentication may refresh, and invalid cursors follow a reset path. Tests should use an injected clock to verify policy quickly.
Common Mistakes
- Accepting any timeout-like result instead of distinguishing application expiry, gateway failure, and client abort.
- Coordinating event publication with arbitrary sleeps that create slow, flaky tests.
- Forgetting to pass the returned cursor on reconnect.
- Predicting or decoding opaque cursor values in automation.
- Testing delivery but never checking that cancelled waiters and subscriptions are released.
- Setting the client timeout shorter than the normal server wait.
- Retrying every error immediately and creating reconnect storms.
- Measuring only requests per second while ignoring held connections and waiter memory.
- Sharing users, topics, or cursors across parallel tests.
- Using a test-runner retry to hide a real disconnect race.
- Logging credentials or sensitive cursor contents in failure artifacts.
Conclusion
Testing long polling APIs is controlled concurrency testing at HTTP scale. Define event, empty, cancellation, cursor, retry, and timeout semantics first. Then automate deterministic state transitions and prove both the client-visible result and server-side cleanup.
Start with one held poll released by a known event, one normal empty expiry, and one client cancellation. Add cursor reconnect next. Once those lifecycle cases are stable, proxy and load tests can reveal capacity problems without confusing them with basic correctness defects.
Interview Questions and Answers
How would you design a long polling API test strategy?
I start from the response and cursor contract, then cover immediate data, delayed data, normal empty expiry, cancellation, reconnect, retention gaps, errors, and actor isolation. Component tests control events and time deterministically. Integration tests cover the broker and real socket, while deployed tests cover ingress timeouts and capacity.
How do you distinguish a server wait expiry from a gateway timeout?
I assert the application-defined status, headers, body, and elapsed range for normal expiry, and correlate it with a server completion reason. A gateway timeout has a different status or transport signature and usually lacks the application's cursor metadata. I also verify configured timeout ordering.
What cursor cases are essential?
I test the initial cursor, normal next cursor, reuse of an old cursor, malformed and future values, cross-user or cross-topic use, and retention expiry. I treat opaque cursors as exact tokens and verify event IDs rather than decoding their implementation.
How would you test a disconnect race?
I synchronize at known server states, such as waiter registered or response beginning, then terminate the client connection. I assert no write occurs to the abandoned response, subscriptions and timers are released, and any event remains available according to the delivery guarantee.
Why can fixed retry delays be dangerous?
Many clients can fail together and reconnect together, creating a second outage through synchronized load. I test bounded backoff with jitter using an injected clock and random source, and inspect the distribution of reconnect attempts after a simulated outage.
What metrics make long polling observable?
I want active polls, completion reasons, hold duration, events per response, cursor-gap errors, client disconnects, reconnect rate, overload rejections, fan-out latency, and cleanup latency. These metrics separate healthy waiting from stalled or leaking requests.
How would you keep long polling tests from hanging CI?
Every client request has an abort budget, every synchronization wait is bounded, and teardown tracks and cancels outstanding requests. The server exposes active waiter state, and fixtures close only after it returns to zero. The broader test timeout remains a final guard rather than the primary control.
Frequently Asked Questions
How do you test a long polling API without slow tests?
Make the application wait configurable and expose a deterministic way to observe waiter registration and publish events. Use small durations in component tests, while keeping one deployed test with production-like timeout ordering.
What status should a long poll return when no data arrives?
The API must define it. Common designs use `204 No Content` or `200` with an empty representation, but a test should enforce the documented choice and cursor behavior rather than assuming one convention.
How do you test long poll cancellation?
Start a request, wait until the server confirms it is held, abort the client, and assert the client outcome. Then verify active waiters, timers, and dependency subscriptions are released and a later event is not sent to the closed response.
How do you prevent duplicate events in long polling tests?
Give every event a stable unique ID and collect IDs across response boundaries. Reconnect using the exact server-returned cursor and assert behavior for both the newest cursor and deliberate reuse of an older cursor.
Should long polling clients retry immediately?
They can usually repoll after a normal event or empty completion. Transient failures should use the product's bounded backoff and jitter policy, while rate limits, authentication failures, and invalid cursors need outcome-specific handling.
What timeout order should long polling use?
The normal application wait should generally finish before infrastructure request limits, and the client budget should permit that response plus network margin. The test runner should have the largest safety budget so a more specific layer reports first.
What is the main performance risk in long polling?
The main risk is often concurrent held state, including connections, waiter objects, subscriptions, and fan-out work, rather than raw request rate. Load tests should include cancellation and recovery so resource cleanup is measured too.