The Problem With "Real-Time" in Next.js
Next.js is brilliant for building fast, SEO-friendly web apps. But the moment a client says "we need live updates" — dashboards, notifications, chat, order tracking — things get complicated fast.
The framework wasn't built around persistent connections. It was built around requests and responses. So when you need data to push instead of pull, you have to make a deliberate architectural choice.
This article breaks down the three main approaches — Polling, Server-Sent Events (SSE), and WebSockets — when to use each, and how to implement them without overengineering your stack.
Understanding the Three Approaches
1. Polling — The Blunt Instrument
Polling is the simplest form of "real-time." Your client asks the server "anything new?" on a set interval. It's not truly real-time, but for many use cases, it's good enough.
// Simple polling with useEffect
import { useEffect, useState } from 'react';
export default function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch('/api/data');
const json = await res.json();
setData(json);
}, 3000); // poll every 3 seconds
return () => clearInterval(interval);
}, []);
return <div>{JSON.stringify(data)}</div>;
}When to use it: Low-stakes updates — analytics dashboards, status checks, non-critical feeds. If stale data for 3–5 seconds is acceptable, polling is perfectly valid.
When to avoid it: High-frequency updates, large payloads, or when server load is a concern. Polling hammers your API whether there's new data or not.
2. Server-Sent Events (SSE) — The Underrated Middle Ground
SSE is a one-way, server-to-client stream over HTTP. The connection stays open, and the server pushes updates whenever there's something new. No new request needed from the client.
Next.js App Router makes SSE surprisingly clean to implement.
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const interval = setInterval(() => {
const message = `data: ${JSON.stringify({ time: new Date() })}\n\n`;
controller.enqueue(encoder.encode(message));
}, 1000);
// Clean up on disconnect
setTimeout(() => {
clearInterval(interval);
controller.close();
}, 30000);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}// Client-side consumption
import { useEffect, useState } from 'react';
export default function LiveFeed() {
const [message, setMessage] = useState('');
useEffect(() => {
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = (e) => {
setMessage(JSON.parse(e.data));
};
return () => eventSource.close();
}, []);
return <div>{JSON.stringify(message)}</div>;
}When to use it: Notifications, live logs, stock tickers, order status updates, activity feeds. Any scenario where the server talks, the client listens.
When to avoid it: When the client also needs to send frequent data back. SSE is one-directional. For two-way communication, WebSockets are the right call.
3. WebSockets — Full Duplex, Full Responsibility
WebSockets maintain a persistent, bidirectional connection between client and server. Both sides can send and receive data freely. This is the right tool for chat apps, collaborative editors, multiplayer games, or any feature where both ends talk constantly.
Next.js doesn't natively support WebSockets in API routes — but you can wire them up using a custom server or, more practically, a dedicated WebSocket service.
// server.js — custom Next.js server with ws
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { WebSocketServer } = require('ws');
const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
console.log('Client connected');
ws.on('message', (message) => {
// Broadcast to all connected clients
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message.toString());
}
});
});
ws.on('close', () => console.log('Client disconnected'));
});
server.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
});// Client-side WebSocket hook
import { useEffect, useRef, useState } from 'react';
export function useWebSocket(url) {
const ws = useRef(null);
const [messages, setMessages] = useState([]);
useEffect(() => {
ws.current = new WebSocket(url);
ws.current.onmessage = (e) => {
setMessages((prev) => [...prev, e.data]);
};
return () => ws.current.close();
}, [url]);
const send = (msg) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(msg);
}
};
return { messages, send };
}When to use it: Chat, collaborative tools, live cursors, multiplayer features, trading interfaces.
When to avoid it: When you don't need bidirectional communication. WebSockets carry real infrastructure overhead — connection management, reconnect logic, scaling. Don't reach for them when SSE handles the job.
Quick Comparison
| Feature | Polling | SSE | WebSockets |
|---|---|---|---|
| Direction | Client → Server | Server → Client | Both |
| Complexity | Low | Medium | High |
| Latency | High | Low | Lowest |
| Next.js Support | Native | Native (App Router) | Custom server |
| Best For | Simple status checks | Notifications, feeds | Chat, collaboration |
| Server Load | High | Medium | Medium–High |
Production Considerations
Reconnection Logic
EventSource reconnects automatically. WebSocket clients don't — you need to handle that yourself.
Scaling Beyond One Server
Both SSE and WebSocket connections are server-bound. If you scale horizontally, a client connected to Server A won't receive events published on Server B. The fix is a pub/sub layer — Redis with pub/sub is the standard choice here.
// Publish an event (from any server)
await redis.publish('updates', JSON.stringify({ type: 'order_update', id: 42 }));
// Subscribe on each server and forward to connected clients
redis.subscribe('updates', (message) => {
const data = JSON.parse(message);
broadcastToConnectedClients(data);
});Authentication
Don't forget to protect your real-time endpoints. For SSE, validate the session in the route handler. For WebSockets, pass a token as a query parameter and validate on connection.
// SSE route with auth check
export async function GET(req) {
const session = await getServerSession();
if (!session) return new Response('Unauthorized', { status: 401 });
// proceed with stream...
}Which One Should You Actually Pick?
Here's the honest answer: most apps don't need WebSockets. The allure of bidirectional communication is real, but so is the operational complexity that comes with it.
- If updates are infrequent and a few seconds of delay is fine — use polling.
- If the server needs to push data to the client in near real-time — use SSE.
- If you genuinely need both sides talking simultaneously — use WebSockets.
Start with the simplest solution that meets your requirements. You can always upgrade the transport layer later — but over-engineering it from day one will cost you time you don't have.
Final Thoughts
Real-time features are where frontend meets infrastructure. The code is manageable — the hard part is understanding the trade-offs and picking the right tool before you've written 2,000 lines of the wrong one.
Ship fast. Scale smart. Keep it clean.
