DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

SSE vs WebSockets in Next.js App Router: Real-Time Done Right (2026)

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' },
  });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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, native crypto, 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',
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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! }
);
Enter fullscreen mode Exit fullscreen mode
// 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 });
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Full guide at stacknotice.com/blog/nextjs-realtime-sse-websockets-2026

Top comments (0)