Every production app eventually needs real-time features. The problem is most guides were written for Pages Router, most examples use Express, and the "SSE vs WebSockets" debate usually skips the one thing that matters most for Next.js developers: where you're deploying.
This guide covers both approaches with real App Router code — and explains what actually breaks on Vercel and why.
The fundamental difference
Server-Sent Events (SSE) are a browser-native feature built on HTTP. The client makes a single request, the server keeps the connection open and pushes data whenever it wants. One direction only: server → client.
WebSockets are a separate protocol. After an HTTP handshake, both sides can send data at any time. Truly bidirectional, persistent connection.
| SSE | WebSockets | |
|---|---|---|
| Direction | Server → Client only | Bidirectional |
| Protocol | HTTP | ws:// or wss:// |
| Reconnection | Automatic (built-in) | Manual |
| Vercel Serverless | ❌ 10s hard timeout | ❌ Not supported |
| Vercel Edge | ✅ Works | ❌ Not supported |
The Vercel problem no one tells you
Here's what happens when you naively implement SSE in a Next.js Route Handler on Vercel:
// app/api/events/route.ts — THIS BREAKS ON VERCEL SERVERLESS
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Killed after 10 seconds on Vercel Serverless (free tier)
// 60 seconds on Pro — still not enough for persistent connections
const interval = setInterval(() => {
controller.enqueue(encoder.encode('data: ping\n\n'));
}, 1000);
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
});
}
Vercel Serverless Functions have a 10-second execution timeout on the free plan, 60 seconds on Pro. Your SSE connection dies silently after that — the client reconnects, dies again, and you end up with a flood of reconnection attempts.
The fix is one line: Edge Runtime.
// app/api/events/route.ts
export const runtime = 'edge'; // This line changes everything
export async function GET(request: Request) {
// Now this stays open as long as the client is connected
}
Edge Functions run on Vercel's edge network with a different execution model — they don't have the same hard timeout constraints for streaming responses.
Note: Edge Functions can't use Node.js APIs like
fs, nativecrypto, or native addons. Most TCP-based database clients won't work directly — use HTTP-based clients like Neon's serverless driver.
For WebSockets: Vercel doesn't support them at all, regardless of runtime. You need an external service.
When to use SSE
SSE is the right choice when:
- Server pushes, client only listens — notifications, live feeds, dashboards
- You're on Vercel — works with Edge Runtime, zero extra infrastructure
- You want simplicity — HTTP-native, no extra libraries, built-in reconnection
Real use cases:
- Activity feed ("User X just joined the team")
- Progress updates for background jobs
- AI streaming responses (the ChatGPT-style streamed output)
- Real-time log tailing
When to use WebSockets
WebSockets are the right choice when:
- The client sends data frequently — chat, collaborative editing, live cursors
- Latency is critical — gaming, financial trading
- You control your infrastructure — self-hosted or using Railway/Render
If you need WebSockets on Vercel, you need an external service. Pusher and Ably are the standard choices.
Implementing SSE in Next.js App Router
// app/api/notifications/route.ts
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
// Always validate auth — don't trust URL params blindly
const session = await getServerSession();
if (!session || session.userId !== userId) {
return new Response('Unauthorized', { status: 401 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send initial connection confirmation
controller.enqueue(
encoder.encode('data: {"type":"connected"}\n\n')
);
const interval = setInterval(async () => {
try {
const events = await getNewEvents(userId);
if (events.length > 0) {
const payload = JSON.stringify({ type: 'events', data: events });
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
}
} catch (error) {
clearInterval(interval);
controller.close();
}
}, 2000);
// Clean up when client disconnects
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-store',
'Connection': 'keep-alive',
},
});
}
Client-side SSE
// hooks/useNotifications.ts
'use client';
import { useEffect, useState } from 'react';
export function useNotifications(userId: string | undefined) {
const [events, setEvents] = useState<AppEvent[]>([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!userId) return;
const eventSource = new EventSource(
`/api/notifications?userId=${encodeURIComponent(userId)}`
);
eventSource.onopen = () => setConnected(true);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'events') {
setEvents(prev => [...data.data, ...prev].slice(0, 100));
}
};
eventSource.onerror = () => setConnected(false);
// EventSource reconnects automatically on error — no manual handling needed
return () => {
eventSource.close();
setConnected(false);
};
}, [userId]);
return { events, connected };
}
EventSource reconnects automatically on error — this is one of SSE's biggest advantages over raw WebSockets.
Implementing WebSockets with Pusher
Pusher is the lowest-friction path for WebSockets on Vercel. Free tier: 200k messages/day, 100 concurrent connections.
npm install pusher pusher-js
// lib/pusher.ts
import Pusher from 'pusher';
import PusherJs from 'pusher-js';
// Server-side client (has the secret — never expose to client)
export const pusherServer = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
secret: process.env.PUSHER_SECRET!,
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
useTLS: true,
});
// Client-side instance (only uses the public key)
export const pusherClient = new PusherJs(
process.env.NEXT_PUBLIC_PUSHER_KEY!,
{ cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER! }
);
// app/api/messages/route.ts
import { pusherServer } from '@/lib/pusher';
export async function POST(request: Request) {
const session = await getServerSession();
if (!session) return new Response('Unauthorized', { status: 401 });
const { content, channelId } = await request.json();
// Persist first, then broadcast
const [saved] = await db.insert(messages).values({
content,
channelId,
userId: session.userId,
}).returning();
await pusherServer.trigger(`channel-${channelId}`, 'new-message', {
id: saved.id,
content,
userId: session.userId,
timestamp: saved.createdAt,
});
return Response.json({ success: true });
}
// hooks/useMessages.ts
'use client';
import { useEffect, useState } from 'react';
import { pusherClient } from '@/lib/pusher';
export function useMessages(channelId: string, initialMessages: Message[]) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
useEffect(() => {
const channel = pusherClient.subscribe(`channel-${channelId}`);
channel.bind('new-message', (data: Message) => {
setMessages(prev => [...prev, data]);
});
return () => {
channel.unbind_all();
pusherClient.unsubscribe(`channel-${channelId}`);
};
}, [channelId]);
return messages;
}
Pass initialMessages from a Server Component via props — the page loads with existing history, then live updates via Pusher.
Decision framework
Real-time feature needed
│
▼
Does the client send data to the server in real time?
│
┌────┴────┐
YES NO
│ │
▼ ▼
WebSockets SSE
│ │
▼ ▼
On Vercel? On Vercel?
│ │
YES YES
│ │
▼ ▼
Use Pusher Add export const
or Ably runtime = 'edge'
Common mistakes
Missing the Edge Runtime export — If SSE works locally but dies after ~10 seconds on Vercel, you forgot export const runtime = 'edge'.
No cleanup in useEffect — Always close EventSource and disconnect WebSocket clients in the useEffect cleanup.
Trusting URL parameters for auth — Always validate auth inside the SSE route handler:
export async function GET(request: Request) {
const session = await getServerSession();
if (!session) return new Response('Unauthorized', { status: 401 });
const requestedUserId = new URL(request.url).searchParams.get('userId');
if (requestedUserId !== session.userId) {
return new Response('Forbidden', { status: 403 });
}
// Safe to proceed
}
Full guide at stacknotice.com/blog/nextjs-realtime-sse-websockets-2026
Top comments (0)