
WebSockets vs Server-Sent Events: Stop Defaulting to WebSockets
Every time someone says "we need real-time updates," the next words in the conversation are "so we'll use WebSockets." I've watched this happen at least fifteen times. And maybe ten of those times, Server-Sent Events would have been simpler, cheaper to operate, and good enough.
WebSockets are genuinely necessary in some cases. But they have a cost, and the cost is higher than most developers appreciate until they're in production.
The Fundamental Difference
WebSockets are a bidirectional protocol. After the handshake, either side client or server can send messages at any time. A single TCP connection stays open for the duration of the session.
Server-Sent Events are unidirectional. The server pushes events to the client. The client wants to send something back? It makes a normal HTTP request. SSE uses a persistent HTTP connection with Content-Type: text/event-stream, but the data only flows one way.
That's the whole distinction. Everything else follows from it.
What WebSockets Cost
Connection management. WebSockets maintain open connections. If you have 10,000 concurrent users, you have 10,000 open connections. This affects how you deploy: stateless horizontally-scaled servers don't work cleanly because each user's connection is pinned to a specific server process. You need sticky sessions or a message broker (like Redis pub/sub) to route messages to the right process.
Proxy and infrastructure complexity. Load balancers, reverse proxies, CDNs many of these have timeout settings that close idle connections. WebSocket connections may require explicit configuration to survive. Corporate firewalls sometimes block WebSocket upgrades entirely.
Reconnection logic. When the connection drops (and it will), the client needs to reconnect and potentially resubscribe to channels, handle missed messages, and reconcile state.
None of these are insurmountable. But they're all things you need to think about. With SSE, most of them go away.
What SSE Does Well
SSE rides on normal HTTP. Your existing infrastructure load balancers, reverse proxies, CDNs works without special configuration. CORS works normally. Auth headers work normally.
The browser handles reconnection automatically. If the connection drops, the browser will reconnect and send the last event ID it received in the Last-Event-ID header, so you can resume from where you left off.
The browser API is simple:
javascriptconst es = new EventSource('/api/events', { withCredentials: true, // send cookies }) es.addEventListener('notification', (event) => { const data = JSON.parse(event.data) showNotification(data) }) es.addEventListener('status-update', (event) => { const { jobId, status } = JSON.parse(event.data) updateJobStatus(jobId, status) }) es.onerror = (error) => { // Browser retries automatically — you usually don't need to handle this console.error('SSE error:', error) } // Close when done es.close()
Implementing SSE in Node.js
typescript// GET /api/events — long-lived connection export function sseHandler(req: Request, res: Response) { // SSE headers res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') // Disable Nginx buffering // Send an initial comment to keep the connection alive res.write(': connected\n\n') const userId = req.user.id // Subscribe to events for this user const unsubscribe = eventBus.subscribe(userId, (event) => { sendSSEEvent(res, event.type, event.data) }) // Keep alive every 30 seconds to prevent proxy timeouts const keepAlive = setInterval(() => { res.write(': ping\n\n') }, 30000) // Cleanup when client disconnects req.on('close', () => { clearInterval(keepAlive) unsubscribe() }) } function sendSSEEvent(res: Response, type: string, data: unknown, id?: string) { if (id) res.write(`id: ${id}\n`) res.write(`event: ${type}\n`) res.write(`data: ${JSON.stringify(data)}\n\n`) }
The event format is simple text. id: is optional but enables resume-from-last-event. event: lets you use different addEventListener handlers on the client. data: is the payload. Two newlines end the event.
A Real Pattern: Job Progress Updates
This is probably the most common use case I implement with SSE. You kick off a long-running job and stream progress back to the UI.
typescript// POST /api/exports start the job and return a job ID app.post('/api/exports', async (req, res) => { const jobId = await exportQueue.add('generate-report', req.body) res.json({ jobId }) }) // GET /api/exports/:jobId/events stream progress app.get('/api/exports/:jobId/events', (req, res) => { const { jobId } = req.params res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.write(': connected\n\n') const interval = setInterval(async () => { const job = await exportQueue.getJob(jobId) if (!job) { sendSSEEvent(res, 'error', { message: 'Job not found' }) res.end() clearInterval(interval) return } const state = await job.getState() const progress = job.progress sendSSEEvent(res, 'progress', { state, progress, jobId }) if (state === 'completed') { sendSSEEvent(res, 'complete', { downloadUrl: `/api/exports/${jobId}/download`, }) res.end() clearInterval(interval) } if (state === 'failed') { sendSSEEvent(res, 'error', { message: job.failedReason }) res.end() clearInterval(interval) } }, 1000) req.on('close', () => clearInterval(interval)) })
typescript// React hook function useJobProgress(jobId: string | null) { const [state, setState] = useState<{ status: 'idle' | 'running' | 'complete' | 'error' progress: number downloadUrl?: string error?: string }>({ status: 'idle', progress: 0 }) useEffect(() => { if (!jobId) return const es = new EventSource(`/api/exports/${jobId}/events`, { withCredentials: true, }) es.addEventListener('progress', (e) => { const { progress } = JSON.parse(e.data) setState((s) => ({ ...s, status: 'running', progress })) }) es.addEventListener('complete', (e) => { const { downloadUrl } = JSON.parse(e.data) setState({ status: 'complete', progress: 100, downloadUrl }) es.close() }) es.addEventListener('error', (e) => { const { message } = JSON.parse(e.data) setState((s) => ({ ...s, status: 'error', error: message })) es.close() }) return () => es.close() }, [jobId]) return state }
Clean, self-contained, and it works across every infrastructure setup I've tried it in.
When You Actually Need WebSockets
Collaborative editing (Google Docs-style), multiplayer games, live trading terminals, video/audio call signaling these genuinely need bidirectional real-time communication. When the client needs to send high-frequency data to the server (mouse positions, keystrokes, game inputs), SSE won't work.
A chat app is the edge case. Simple chat with a send button? SSE for receiving messages, regular HTTP POST for sending. Works fine. Typing indicators, presence, real-time collaborative features, reaction animations? Now you might want WebSockets.
But be honest about what you're actually building. Most "real-time" features are server-to-client: notifications, status updates, data refreshes, progress bars. For all of those, SSE is the better default.
SSE in Next.js App Router
Route handlers support streaming responses natively:
typescript// app/api/events/route.ts import { NextRequest } from 'next/server' export async function GET(req: NextRequest) { const encoder = new TextEncoder() const stream = new ReadableStream({ start(controller) { const send = (type: string, data: unknown) => { controller.enqueue( encoder.encode(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`) ) } // Subscribe to events const unsub = eventBus.subscribe(userId, (event) => { send(event.type, event.data) }) // Keepalive const ping = setInterval(() => { controller.enqueue(encoder.encode(': ping\n\n')) }, 30000) // Cleanup — Next.js calls this when the client disconnects req.signal.addEventListener('abort', () => { clearInterval(ping) unsub() controller.close() }) }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) }
The req.signal from the AbortSignal API handles cleanup when the client navigates away. No req.on('close', ...) needed — the web-standard API works here.
Use SSE by default for real-time features. Upgrade to WebSockets when the data genuinely needs to flow both ways at high frequency. The operational simplicity you keep is worth it.