You’re building a notification system. Users need to see new alerts the moment they arrive. The obvious solution? WebSockets. But your load balancer doesn’t support them. Your corporate firewall blocks them. Your client’s browser is ancient.
What do you do?
You reach for Long Polling. It’s not glamorous. It’s not cutting edge. But it works everywhere HTTP works, and it’s been quietly powering real-time features for over 15 years.
Let’s understand how this simple technique works and when it still makes sense in 2025.
The Problem With Regular Polling
To understand Long Polling, you first need to understand why regular polling fails at scale.
Regular polling is simple: the client asks the server for updates every few seconds.
sequenceDiagram
participant Client
participant Server
Client->>Server: Any updates?
Server-->>Client: No
Note over Client: Wait 5 seconds
Client->>Server: Any updates?
Server-->>Client: No
Note over Client: Wait 5 seconds
Client->>Server: Any updates?
Server-->>Client: Yes, here's the data
Note over Client: Wait 5 seconds
Client->>Server: Any updates?
Server-->>Client: No
This works, but it has two problems:
Problem 1: Wasted requests. If you poll every 5 seconds and updates arrive once a minute, 11 out of 12 requests return empty. That’s 91% wasted bandwidth and server processing.
Problem 2: Delayed updates. If you poll every 5 seconds and an update arrives right after a poll, users wait up to 5 seconds to see it. Poll more frequently and you waste more resources. Poll less frequently and updates feel sluggish.
You’re stuck choosing between responsiveness and efficiency.
How Long Polling Solves This
Long Polling flips the script. Instead of the client asking repeatedly, it asks once and waits. The server holds the connection open until it has something to say.

The beauty is in the simplicity:
- Client sends an HTTP request
- Server doesn’t respond immediately
- Server waits until it has new data (or a timeout occurs)
- Server sends the response
- Client immediately makes another request
No wasted requests. Updates arrive the instant they’re available. And it’s just plain HTTP.
The Technical Implementation
Let’s build a basic Long Polling system. The server side is where the magic happens.
Server Side (Node.js with Express)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const express = require('express');
const app = express();
// Store for pending notification requests
const waitingClients = new Map();
// Store for user notifications
const notifications = new Map();
app.get('/notifications/:userId', async (req, res) => {
const { userId } = req.params;
const timeout = 30000; // 30 second timeout
// Check if there are already pending notifications
const pending = notifications.get(userId);
if (pending && pending.length > 0) {
const data = pending.splice(0, pending.length);
return res.json({ notifications: data });
}
// No notifications yet, hold the connection
const timeoutId = setTimeout(() => {
waitingClients.delete(userId);
res.json({ notifications: [] });
}, timeout);
// Store the response object so we can respond later
waitingClients.set(userId, { res, timeoutId });
// Clean up if client disconnects
req.on('close', () => {
clearTimeout(timeoutId);
waitingClients.delete(userId);
});
});
// Endpoint to send a notification (called by other services)
app.post('/send-notification', express.json(), (req, res) => {
const { userId, message } = req.body;
// Check if user has a waiting request
const waiting = waitingClients.get(userId);
if (waiting) {
// User is connected and waiting, respond immediately
clearTimeout(waiting.timeoutId);
waitingClients.delete(userId);
waiting.res.json({ notifications: [message] });
} else {
// User not connected, store for later
if (!notifications.has(userId)) {
notifications.set(userId, []);
}
notifications.get(userId).push(message);
}
res.json({ status: 'sent' });
});
app.listen(3000);
Client Side (JavaScript)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class LongPollingClient {
constructor(userId) {
this.userId = userId;
this.isRunning = false;
}
start() {
this.isRunning = true;
this.poll();
}
stop() {
this.isRunning = false;
}
async poll() {
if (!this.isRunning) return;
try {
const response = await fetch(`/notifications/${this.userId}`, {
// Important: don't let browser timeout before server
signal: AbortSignal.timeout(35000)
});
const data = await response.json();
if (data.notifications.length > 0) {
this.handleNotifications(data.notifications);
}
// Immediately start next poll
this.poll();
} catch (error) {
if (error.name === 'AbortError') {
// Timeout, just try again
this.poll();
} else {
// Network error, back off and retry
console.error('Polling error:', error);
setTimeout(() => this.poll(), 5000);
}
}
}
handleNotifications(notifications) {
notifications.forEach(notification => {
console.log('New notification:', notification);
// Update UI, show toast, etc.
});
}
}
// Usage
const client = new LongPollingClient('user-123');
client.start();
The Connection Timeout Dance
The 30 second timeout in the example isn’t random. It’s a careful balance between several constraints:
Browser limits: Most browsers kill idle HTTP connections after 60 to 120 seconds. Your timeout must be shorter.
Proxy limits: Corporate proxies, load balancers, and CDNs often have their own timeouts, typically 30 to 60 seconds. Nginx defaults to 60 seconds. AWS ALB defaults to 60 seconds.
Keep-alive signals: Some proxies drop connections that appear idle. Returning an empty response before the proxy timeout keeps the connection alive.
Here’s the typical flow with timeouts:
sequenceDiagram
participant Client
participant Proxy
participant Server
Client->>Proxy: Long poll request
Proxy->>Server: Forward request
Note over Server: Waiting for data...
Note over Server: 25 seconds pass...
Note over Server: No data yet
Note over Server: Approaching timeout
Server-->>Proxy: Empty response (timeout)
Proxy-->>Client: Forward response
Client->>Proxy: New long poll request
Proxy->>Server: Forward request
Note over Server: 5 seconds pass
Note over Server: Data arrives!
Server-->>Proxy: Response with data
Proxy-->>Client: Forward response
Long Polling vs WebSockets vs Server-Sent Events
Long Polling isn’t the only way to push data to clients. Let’s compare the three main approaches:
| Feature | Long Polling | WebSockets | Server-Sent Events |
|---|---|---|---|
| Connection | New HTTP request each cycle | Single persistent connection | Single persistent connection |
| Direction | Server to client (with tricks) | Bidirectional | Server to client only |
| Protocol | HTTP | WebSocket (ws://, wss://) | HTTP |
| Proxy support | Excellent | Variable | Good |
| Firewall friendly | Yes | Sometimes blocked | Yes |
| Browser support | Universal | Modern browsers | Modern browsers (no IE) |
| Reconnection | Built into pattern | Must implement | Automatic |
| Message efficiency | HTTP overhead each message | Minimal overhead | Minimal overhead |
Choose Long Polling when:
- You need maximum compatibility
- Corporate firewalls block WebSocket traffic
- Your infrastructure doesn’t support persistent connections
- You’re dealing with legacy systems
Choose WebSockets when:
- You need bidirectional communication
- Message frequency is high
- Latency is critical
- You control the infrastructure
Choose Server-Sent Events when:
- You only need server to client updates
- You want simplicity without WebSocket complexity
- HTTP/2 is available (multiplexes efficiently)
Real-World Long Polling: How Facebook Did It
Before Facebook moved to MQTT and eventually their custom protocol, they used Long Polling for their chat and notification systems. Their implementation handled millions of concurrent connections.
Here’s what made it work at scale:
1. Connection Pooling
Instead of one request per user, Facebook multiplexed multiple users onto shared long-poll channels:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Simplified concept of channel-based long polling
app.get('/channel/:channelId', async (req, res) => {
const { channelId } = req.params;
const userIds = getUsersInChannel(channelId);
// Wait for any event relevant to any user in this channel
const event = await waitForEvent(userIds, 30000);
if (event) {
res.json(event);
} else {
res.json({ type: 'heartbeat' });
}
});
This reduced the number of open connections dramatically. Instead of 1 million connections for 1 million users, they might have 100,000 connections each serving 10 users.
2. Sticky Sessions
Long Polling requires the same server to handle the request that has the user’s pending data. Facebook used sticky sessions (also called session affinity) at the load balancer level to route each user’s requests to the same server.
flowchart TD
C1[Client A] --> LB[Load Balancer]
C2[Client B] --> LB
C3[Client C] --> LB
LB -->|"Session: A"| S1[Server 1]
LB -->|"Session: B, C"| S2[Server 2]
S1 --> Q1[User A Queue]
S2 --> Q2[User B Queue]
S2 --> Q3[User C Queue]
style LB fill:#fef3c7,stroke:#d97706,stroke-width:2px
style S1 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style S2 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
3. Graceful Degradation
When servers got overloaded, Facebook would increase the timeout gradually. Under extreme load, Long Polling degrades naturally into regular polling with longer intervals.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getTimeoutBasedOnLoad() {
const serverLoad = getServerLoadPercent();
if (serverLoad < 50) {
return 30000; // Normal: 30 seconds
} else if (serverLoad < 75) {
return 45000; // Medium load: 45 seconds
} else if (serverLoad < 90) {
return 60000; // High load: 60 seconds
} else {
// Critical load: switch to regular polling
return 5000;
}
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Connection Starvation
Browsers limit concurrent connections to the same domain (usually 6 per domain). If you open too many long-poll connections, regular requests can’t get through.
Fix: Use a single long-poll connection per tab, or use a separate subdomain for long polling:
1
2
3
4
5
6
7
8
// Use a dedicated subdomain for long polling
const pollEndpoint = 'https://poll.yoursite.com/notifications';
// Or limit connections per tab
if (!window.longPollActive) {
window.longPollActive = true;
startLongPolling();
}
Pitfall 2: Thundering Herd
When a server restarts or a network hiccup occurs, all clients reconnect simultaneously. This flood can overwhelm the server.
Fix: Add jitter to reconnection timing:
1
2
3
4
5
function reconnectWithJitter() {
// Random delay between 0 and 5 seconds
const jitter = Math.random() * 5000;
setTimeout(() => this.poll(), jitter);
}
Pitfall 3: Memory Leaks
Holding thousands of response objects in memory for waiting clients can cause memory leaks if not cleaned up properly.
Fix: Always handle client disconnections:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.get('/poll', (req, res) => {
const clientId = req.query.id;
// Store the client
clients.set(clientId, res);
// Clean up on disconnect
req.on('close', () => {
clients.delete(clientId);
});
// Clean up on error
req.on('error', () => {
clients.delete(clientId);
});
});
Pitfall 4: Missing Messages During Reconnection
Between when a response is sent and when the next poll request arrives, messages can be lost.
Fix: Use sequence numbers or timestamps:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Client tracks last seen sequence
let lastSeq = 0;
async function poll() {
const response = await fetch(`/poll?since=${lastSeq}`);
const data = await response.json();
if (data.messages.length > 0) {
lastSeq = data.messages[data.messages.length - 1].seq;
}
poll();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Server stores messages with sequence numbers
const messages = [];
let currentSeq = 0;
function addMessage(content) {
messages.push({ seq: ++currentSeq, content, timestamp: Date.now() });
// Clean up old messages after 5 minutes
const cutoff = Date.now() - 300000;
while (messages.length && messages[0].timestamp < cutoff) {
messages.shift();
}
}
app.get('/poll', (req, res) => {
const since = parseInt(req.query.since) || 0;
const newMessages = messages.filter(m => m.seq > since);
if (newMessages.length > 0) {
res.json({ messages: newMessages });
} else {
// Wait for new messages or timeout
// ...
}
});
Scaling Long Polling
For serious production use, you need to think about horizontal scaling. The challenge is that a user’s long-poll request might hit Server A, but the event they’re waiting for arrives at Server B.
Option 1: Redis Pub/Sub
Use Redis to broadcast events across all servers:
flowchart TD
subgraph Clients
C1[Client 1]
C2[Client 2]
C3[Client 3]
end
subgraph Servers
S1[Server 1]
S2[Server 2]
S3[Server 3]
end
R[(Redis Pub/Sub)]
C1 --> S1
C2 --> S2
C3 --> S3
S1 <--> R
S2 <--> R
S3 <--> R
style R fill:#fee2e2,stroke:#dc2626,stroke-width:2px
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Redis = require('ioredis');
const pub = new Redis();
const sub = new Redis();
// Subscribe to user events
sub.subscribe('user-events');
sub.on('message', (channel, message) => {
const { userId, data } = JSON.parse(message);
// Check if this user is waiting on this server
const waiting = waitingClients.get(userId);
if (waiting) {
clearTimeout(waiting.timeoutId);
waitingClients.delete(userId);
waiting.res.json({ data });
}
});
// When an event occurs, publish it
function notifyUser(userId, data) {
pub.publish('user-events', JSON.stringify({ userId, data }));
}
Option 2: Sticky Sessions with Shared State
Route users consistently to the same server, but use shared storage (Redis, database) for event data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Store events in Redis with expiry
async function storeEvent(userId, event) {
const key = `events:${userId}`;
await redis.rpush(key, JSON.stringify(event));
await redis.expire(key, 300); // 5 minute expiry
}
// Poll endpoint checks Redis
app.get('/poll/:userId', async (req, res) => {
const { userId } = req.params;
const key = `events:${userId}`;
// Check for existing events
const events = await redis.lrange(key, 0, -1);
if (events.length > 0) {
await redis.del(key);
return res.json({ events: events.map(JSON.parse) });
}
// No events, wait with polling on Redis
// ...
});
Performance Numbers
To give you a sense of what Long Polling can handle:
| Metric | Typical Value |
|---|---|
| Connections per server | 10,000 to 50,000 |
| Memory per connection | 2KB to 10KB |
| Timeout duration | 20 to 60 seconds |
| Reconnection delay | 0 to 5 seconds (with jitter) |
| Message latency | Near-instant to timeout length |
| HTTP overhead per message | 400 to 800 bytes headers |
Compare this to WebSockets:
| Metric | WebSocket | Long Polling |
|---|---|---|
| Connection overhead | One handshake | Headers every request |
| Message overhead | 2 to 14 bytes | 400+ bytes |
| Connections per server | 100,000+ | 10,000 to 50,000 |
| Bidirectional | Native | Requires separate request |
When Long Polling Still Makes Sense in 2025
Despite WebSockets and Server-Sent Events being widely available, Long Polling hasn’t disappeared. Here’s where you’ll still find it:
Enterprise environments: Many corporate networks and proxies still don’t handle WebSockets well. Long Polling just works.
Serverless architectures: AWS Lambda, Cloud Functions, and other serverless platforms don’t support persistent connections. Long Polling works within HTTP request timeouts.
Simple notification systems: If you’re sending a few notifications per minute to users, the overhead of Long Polling is negligible, and the implementation is simpler than setting up WebSocket infrastructure.
Fallback mechanisms: Even apps that primarily use WebSockets often fall back to Long Polling when WebSocket connections fail.
Key Takeaways
1. Long Polling is HTTP polling done right. The server holds the connection until it has data, eliminating wasted requests and reducing latency.
2. Timeouts are critical. Stay under browser and proxy limits. 30 seconds is a safe default.
3. Handle reconnection gracefully. Add jitter to prevent thundering herds. Use sequence numbers to avoid missing messages.
4. Clean up resources. Remove waiting clients when they disconnect. Set expiry on stored messages.
5. Consider scaling early. Redis Pub/Sub or sticky sessions with shared state let you scale horizontally.
6. Know when to graduate. If you’re sending dozens of messages per second per user, it’s time for WebSockets. Long Polling works best for low to medium frequency updates.
Long Polling isn’t the future of real-time web. But it’s a reliable, battle-tested technique that works in environments where nothing else will. Sometimes the best solution isn’t the newest one.
Want to understand other real-time communication approaches? Check out WebSockets Explained for the persistent connection alternative and How Stock Brokers Handle Real-Time Price Updates to see WebSockets in action at massive scale.
References: Comet (Wikipedia), RFC 6202: HTTP Long Polling, Facebook Engineering Blog