Adding real-time functionality to a web application — live notifications, chat, live dashboards, collaborative editing — requires choosing between three fundamentally different communication patterns: WebSockets, Server-Sent Events (SSE), and Long Polling. Each solves the same problem differently and excels in different scenarios.
Choosing the wrong approach creates scalability problems, unnecessary complexity, or browser compatibility issues. This post explains each pattern, provides implementation examples, and gives you a concrete decision framework.
The Core Difference
COMMUNICATION PATTERNS:
Long Polling:
Client ──► Server (request)
Client ◄── Server (waits and responds when data ready)
Client ──► Server (immediately re-polls)
Server-Sent Events:
Client ──► Server (one-time connection request)
Client ◄── Server (continuous stream of events, server-to-client only)
Client Server (no client-to-server communication after connection)
WebSockets:
Client ◄──► Server (full-duplex, bidirectional)
Client ◄──► Server (both sides send messages freely)Long Polling
The client sends an HTTP request. The server holds the connection open until it has data to send, then responds. The client immediately sends another request, creating a polling loop.
When to use it:
- You need maximum compatibility (works everywhere HTTP works).
- Real-time updates are infrequent (less than once every few seconds).
- You can't use WebSockets due to infrastructure constraints (some proxies block upgrades).
Next.js Implementation
// app/api/notifications/poll/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const lastEventId = searchParams.get('lastEventId') ?? '0';
const userId = await getUserIdFromRequest(request);
// Hold the connection for up to 25 seconds, checking for new events
const startTime = Date.now();
const timeout = 25000; // 25 second hold
while (Date.now() - startTime < timeout) {
const newNotifications = await db.query(
'SELECT * FROM notifications WHERE user_id = $1 AND id > $2 ORDER BY id ASC LIMIT 10',
[userId, lastEventId]
);
if (newNotifications.rows.length > 0) {
return NextResponse.json({
notifications: newNotifications.rows,
lastEventId: newNotifications.rows.at(-1)!.id,
});
}
// Wait 1 second before checking again
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Timeout: return empty response, client will re-poll
return NextResponse.json({ notifications: [], lastEventId });
}// Client-side long polling loop
async function startLongPolling(userId: string) {
let lastEventId = '0';
while (true) {
try {
const response = await fetch(
`/api/notifications/poll?lastEventId=${lastEventId}`
);
const data = await response.json();
if (data.notifications.length > 0) {
renderNotifications(data.notifications);
lastEventId = data.lastEventId;
}
} catch (error) {
// Connection error — wait before retrying
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}Drawbacks: Each poll holds a server connection. At scale (10,000 concurrent users), this can exhaust connection pools.
Server-Sent Events (SSE)
SSE creates a persistent HTTP connection from client to server. The server pushes events whenever it has data. The connection is one-directional — the client cannot send messages back.
When to use it:
- Server needs to push updates to the client only (no client-to-server after connection).
- You want a simple implementation without WebSocket complexity.
- Use cases: live activity feeds, notification streams, progress updates, log streaming.
Next.js Implementation
// app/api/notifications/stream/route.ts
export async function GET(request: Request) {
const userId = await getUserIdFromRequest(request);
const encoder = new TextEncoder();
let lastEventId = '0';
const stream = new ReadableStream({
async start(controller) {
// Send an initial ping to establish the connection
controller.enqueue(encoder.encode(': ping\n\n'));
// Check for new events every 2 seconds
const interval = setInterval(async () => {
try {
const newNotifications = await db.query(
'SELECT * FROM notifications WHERE user_id = $1 AND id > $2 ORDER BY id ASC',
[userId, lastEventId]
);
for (const notification of newNotifications.rows) {
// SSE format: "data: {json}\n\n"
const event = `id: ${notification.id}\ndata: ${JSON.stringify(notification)}\n\n`;
controller.enqueue(encoder.encode(event));
lastEventId = notification.id;
}
} catch (error) {
clearInterval(interval);
controller.close();
}
}, 2000);
// Clean up when the client disconnects
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}// Client-side: EventSource API — built into every browser
function connectToNotificationStream() {
const eventSource = new EventSource('/api/notifications/stream');
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
renderNotification(notification);
};
eventSource.onerror = () => {
// Browser automatically reconnects on error
console.log('SSE connection lost, reconnecting...');
};
// Cleanup
return () => eventSource.close();
}Key advantage: Browsers automatically reconnect SSE streams and support the Last-Event-ID header for resuming after disconnection.
WebSockets
WebSockets provide a full-duplex, persistent connection. Both server and client can send messages at any time after the handshake.
When to use it:
- You need bidirectional communication (client sends and receives messages).
- Use cases: real-time chat, collaborative editing, live multiplayer games, trading dashboards.
- High-frequency updates (millisecond latency matters).
Next.js + Socket.io Implementation
pnpm add socket.io socket.io-client// server.ts — Custom Next.js server with Socket.io
import { createServer } from 'http';
import next from 'next';
import { Server } from 'socket.io';
const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handler = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer(handler);
const io = new Server(httpServer, {
cors: { origin: process.env.NEXT_PUBLIC_URL },
});
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}`);
// Join a chat room
socket.on('join-room', (roomId: string) => {
socket.join(roomId);
io.to(roomId).emit('user-joined', { userId: socket.id });
});
// Broadcast message to room
socket.on('send-message', (data: { roomId: string; message: string; userId: string }) => {
io.to(data.roomId).emit('new-message', {
id: crypto.randomUUID(),
message: data.message,
userId: data.userId,
timestamp: new Date().toISOString(),
});
});
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
});
});
httpServer.listen(3000);
});// Client-side
import { io } from 'socket.io-client';
const socket = io(process.env.NEXT_PUBLIC_URL!);
socket.on('connect', () => {
socket.emit('join-room', 'room-123');
});
socket.on('new-message', (message) => {
renderMessage(message);
});
function sendMessage(text: string) {
socket.emit('send-message', {
roomId: 'room-123',
message: text,
userId: currentUserId,
});
}Comparison Table
| Feature | Long Polling | SSE | WebSockets |
|---|---|---|---|
| Direction | Client-initiated, server responds | Server → Client only | Bidirectional |
| Protocol | HTTP | HTTP | WebSocket (ws://) |
| Browser support | Universal | Universal | Universal |
| Auto-reconnect | Manual | ✅ Built-in | Manual |
| Latency | High (1s+ polling interval) | Low (immediate push) | Very low (ms) |
| Complexity | Low | Low | High |
| Serverless-friendly | ✅ Yes | ✅ Yes | ⚠️ Difficult |
| Proxy-friendly | ✅ Yes | ✅ Yes | ⚠️ Sometimes blocked |
| Scale | ⚠️ Connection overhead | ✅ Good | ✅ Good |
Decision Framework
Does the client need to SEND real-time messages to the server?
├── YES → WebSockets
└── NO
│
Does the server need to push updates immediately (< 1s latency)?
├── YES → Server-Sent Events
└── NO (updates every few seconds are fine)
│
Are you deploying to serverless/edge functions?
├── YES → Long Polling (or SSE)
└── NO → Server-Sent Events (simpler than Long Polling)Conclusion
There is no universally superior real-time pattern. Long polling is the safest compatibility choice when updates are infrequent. Server-Sent Events are the right default for server-to-client push with minimal complexity. WebSockets are necessary when bidirectional, low-latency communication is required. Match the pattern to your use case, not to the technology you find most interesting, and you'll save significant debugging time down the road.