Long Polling
The idea
Instead of responding immediately with “nothing changed,” the server holds the request open. When data becomes available, the server responds. The client immediately sends another request. The connection is always open, ready for the next update.
Short polling: Client → Server → "nothing" → wait 5s → Client → Server → "nothing" → ...
Long polling: Client → Server → [waits...] → "new task!" → Client → Server → [waits...] → ... Long polling eliminates empty responses. The server only responds when it has something to say.
Implementation
The server keeps a list of waiting clients. When data changes, it resolves their pending responses:
// src/long-poll.ts
type Waiter = {
boardId: string;
resolve: (data: any) => void;
timer: ReturnType<typeof setTimeout>;
};
const waiters: Waiter[] = [];
const TIMEOUT = 30_000; // 30 seconds
export function waitForUpdate(boardId: string): Promise<Response> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
// Timeout — send empty response, client will reconnect
removeWaiter(waiter);
resolve(Response.json({ data: [], timeout: true }));
}, TIMEOUT);
const waiter: Waiter = {
boardId,
resolve: (data: any) => {
clearTimeout(timer);
removeWaiter(waiter);
resolve(Response.json({ data, timestamp: new Date().toISOString() }));
},
timer,
};
waiters.push(waiter);
});
}
export function notifyWaiters(boardId: string, data: any): void {
const matching = waiters.filter((w) => w.boardId === boardId);
for (const waiter of matching) {
waiter.resolve(data);
}
}
function removeWaiter(waiter: Waiter): void {
const index = waiters.indexOf(waiter);
if (index !== -1) waiters.splice(index, 1);
} The long-poll route
route.get("/boards/:boardId/poll", {
resolve: (c) => {
return waitForUpdate(c.params.boardId);
},
}); When a client calls GET /boards/board-1/poll, the request hangs until either a task update happens or the 30-second timeout elapses.
Triggering notifications
Update the task routes to notify waiters when data changes:
// In POST /tasks — after inserting the task:
const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
notifyWaiters(listBoardId, [{ type: "task_created", task }]);
return Response.json(task, { status: 201 });
// In PATCH /tasks/:id/move — after updating:
const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(c.params.id);
notifyWaiters(boardId, [{ type: "task_moved", task }]);
return Response.json(task); When Alice creates a task, notifyWaiters resolves Bob’s pending long-poll request with the new task data. Bob’s client immediately sends another long-poll request.
The client
// Client-side long polling
async function longPoll() {
while (true) {
try {
const res = await fetch("/boards/board-1/poll");
const { data, timeout } = await res.json();
if (!timeout && data.length > 0) {
updateUI(data);
}
} catch (err) {
// Connection error — wait before reconnecting
await new Promise((r) => setTimeout(r, 3000));
}
// Immediately reconnect (the loop continues)
}
}
longPoll(); The loop sends a request, waits for a response (which might take up to 30 seconds), processes the data, and immediately sends another request. The connection is always “open” from the client’s perspective.
Tradeoffs
Pros over short polling: No empty responses. Updates arrive as soon as they happen (no fixed interval delay). Less server load when nothing is changing.
Cons: Each pending request holds a server connection. With 1,000 users, 1,000 connections are held open. The server needs to manage the waiter list. Reconnection logic is more complex. Message ordering can be tricky (two updates arrive while the client is reconnecting).
The timeout
The 30-second timeout is important. Without it, connections hang forever if no updates come. HTTP intermediaries (load balancers, proxies) often kill idle connections after 60 seconds. A 30-second timeout with immediate reconnection stays within those limits.
Exercises
Exercise 1: Implement long polling. Open two terminals: one runs curl http://localhost:3000/boards/board-1/poll (it should hang). The other creates a task. The first terminal should receive the update.
Exercise 2: Wait for the 30-second timeout. Verify the response includes timeout: true and no data.
Exercise 3: Compare the number of requests over 60 seconds: short polling at 5-second intervals (12 requests) vs long polling with no changes (2 requests — one timeout per 30 seconds).
Why does long polling need a timeout?