Chapter 24

Real-Time Features: Server-Sent Events and WebSocket

Two Protocols for Real-Time Communication

When building real-time features, developers typically choose between SSE (Server-Sent Events) and WebSocket. Understanding their fundamental difference is the prerequisite for choosing correctly.

WebSocket is a full-duplex protocol โ€” the client and server can both send messages to each other at any time, true bidirectional communication. It fits scenarios where the client frequently sends data to the server: collaborative editing, real-time games, exchange order books.

SSE is one-directional โ€” the server pushes messages to the client, which establishes a long-lived HTTP connection to listen. If the client needs to send data, it uses ordinary HTTP requests. This apparent "limitation" is actually the right model for most real-time scenarios: notifications, progress bars, streaming output, price feeds โ€” all of these are server-initiated pushes to a passively listening client.

In the Next.js App Router, SSE is natively supported; WebSocket is not. Route Handlers are built on the standard Web Response object, which supports streaming output โ€” exactly what SSE needs. WebSocket requires an HTTP upgrade (101 Switching Protocols), which Next.js Route Handlers do not support.

How SSE Works

SSE is remarkably simple to implement. The server returns a response with Content-Type: text/event-stream, keeps the connection open, and continuously writes formatted data:

data: {"message": "Hello"}\n\n
data: {"message": "World"}\n\n
event: ping\ndata: {}\n\n

Each message ends with two newlines. The client receives these with the browser's native EventSource API:

const es = new EventSource('/api/events')
es.onmessage = (e) => console.log(JSON.parse(e.data))
es.addEventListener('ping', (e) => console.log('ping'))

That is the entire SSE protocol. No complex handshake, no binary framing โ€” pure HTTP.

Implementing SSE in a Route Handler

// app/api/events/route.ts
import { NextRequest } from 'next/server'
import { auth } from '@/auth'

export async function GET(request: NextRequest) {
  const session = await auth()
  if (!session) {
    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')
      )

      // Heartbeat every 30 seconds to prevent connection timeout
      const heartbeat = setInterval(() => {
        try {
          controller.enqueue(encoder.encode(': heartbeat\n\n'))
        } catch {
          clearInterval(heartbeat)
        }
      }, 30000)

      // Clean up when the client disconnects
      request.signal.addEventListener('abort', () => {
        clearInterval(heartbeat)
        controller.close()
      })
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      // Prevent Nginx/proxies from buffering the response
      'X-Accel-Buffering': 'no',
    },
  })
}

Notice : heartbeat\n\n โ€” lines starting with a colon are SSE comments, ignored by the client, but they prevent proxies and load balancers from treating the connection as idle and closing it.

Broadcast Notifications via Pub/Sub

In real scenarios, SSE connections need to receive events triggered by other parts of the server. In a single-process Node.js application, an in-memory event emitter works well:

// lib/eventEmitter.ts
import { EventEmitter } from 'events'

const globalForEmitter = globalThis as unknown as { emitter: EventEmitter }

export const emitter = globalForEmitter.emitter ?? new EventEmitter()
if (process.env.NODE_ENV !== 'production') globalForEmitter.emitter = emitter

// High listener limit to avoid memory leak warnings (one listener per SSE connection)
emitter.setMaxListeners(1000)
// app/api/notifications/route.ts
import { NextRequest } from 'next/server'
import { auth } from '@/auth'
import { emitter } from '@/lib/eventEmitter'

export async function GET(request: NextRequest) {
  const session = await auth()
  if (!session) return new Response('Unauthorized', { status: 401 })

  const userId = session.user.id
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    start(controller) {
      function sendNotification(notification: object) {
        const data = `data: ${JSON.stringify(notification)}\n\n`
        controller.enqueue(encoder.encode(data))
      }

      // Listen for notifications directed at this user
      emitter.on(`notification:${userId}`, sendNotification)

      // Clean up when the connection closes
      request.signal.addEventListener('abort', () => {
        emitter.off(`notification:${userId}`, sendNotification)
        controller.close()
      })
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'X-Accel-Buffering': 'no',
    },
  })
}

Triggering a notification from elsewhere:

// app/api/admin/notify/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { emitter } from '@/lib/eventEmitter'

export async function POST(request: NextRequest) {
  const { userId, message, type } = await request.json()

  emitter.emit(`notification:${userId}`, {
    id: crypto.randomUUID(),
    message,
    type,
    timestamp: new Date().toISOString(),
  })

  return NextResponse.json({ sent: true })
}

Critical limitation: in-memory event emitters only work within a single process. If your application runs multiple instances (Kubernetes, multiple Vercel instances), SSE connections on different processes cannot communicate. Multi-process scenarios require an external message broker such as Redis Pub/Sub or Upstash Redis.

AI Streaming Output: The ChatGPT Pattern

This is currently the most popular SSE use case. The AI SDK's streamText function pairs naturally with Next.js Route Handlers:

// app/api/chat/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { messages } = await request.json()

  const result = await streamText({
    model: openai('gpt-4o'),
    messages,
    system: 'You are a helpful assistant.',
  })

  // toDataStreamResponse() returns an SSE-formatted Response
  return result.toDataStreamResponse()
}
// app/chat/page.tsx
'use client'

import { useChat } from 'ai/react'

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: '/api/chat',
  })

  return (
    <div className="flex flex-col h-screen">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map(m => (
          <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
            <div className="inline-block p-3 rounded-lg bg-gray-100 max-w-xl">
              {m.content}
            </div>
          </div>
        ))}
        {isLoading && <div className="text-gray-400">AI is thinking...</div>}
      </div>
      <form onSubmit={handleSubmit} className="p-4 border-t">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message..."
          className="w-full border rounded px-3 py-2"
        />
      </form>
    </div>
  )
}

Progress Bars for Long-Running Tasks

SSE is ideal for reporting progress on time-consuming server-side operations:

// app/api/process/route.ts
import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { taskId } = await request.json()
  const encoder = new TextEncoder()

  function send(data: object) {
    return encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
  }

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const steps = [
          { step: 1, label: 'Reading data', total: 4 },
          { step: 2, label: 'Processing', total: 4 },
          { step: 3, label: 'Generating report', total: 4 },
          { step: 4, label: 'Done', total: 4 },
        ]

        for (const step of steps) {
          controller.enqueue(send({
            type: 'progress',
            ...step,
            percent: (step.step / step.total) * 100,
          }))
          await new Promise(r => setTimeout(r, 1000)) // simulate real work
        }

        controller.enqueue(send({ type: 'complete', taskId, result: 'Finished' }))
        controller.close()
      } catch (err) {
        controller.enqueue(send({ type: 'error', message: String(err) }))
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  })
}

A Reusable EventSource Hook

// hooks/useSSE.ts
'use client'

import { useEffect, useRef, useState } from 'react'

interface SSEOptions<T> {
  url: string
  onMessage: (data: T) => void
  onError?: (error: Event) => void
  enabled?: boolean
}

export function useSSE<T>({ url, onMessage, onError, enabled = true }: SSEOptions<T>) {
  const [status, setStatus] = useState<'connecting' | 'connected' | 'closed'>('connecting')
  const esRef = useRef<EventSource | null>(null)

  useEffect(() => {
    if (!enabled) return

    const es = new EventSource(url)
    esRef.current = es

    es.addEventListener('open', () => setStatus('connected'))

    es.addEventListener('message', (e) => {
      try {
        onMessage(JSON.parse(e.data))
      } catch {
        console.error('Failed to parse SSE message:', e.data)
      }
    })

    es.addEventListener('error', (e) => {
      setStatus('closed')
      onError?.(e)
      // EventSource reconnects automatically with exponential backoff โ€” no manual handling needed
    })

    return () => {
      es.close()
      setStatus('closed')
    }
  }, [url, enabled])

  return { status, close: () => esRef.current?.close() }
}

WebSocket Alternatives

Next.js Route Handlers do not support WebSocket. If your scenario genuinely requires bidirectional real-time communication, there are three directions:

Option 1: Pusher or Ably (Managed WebSocket Services)

npm install pusher pusher-js
// app/api/pusher/auth/route.ts โ€” Pusher channel authentication
import { NextRequest, NextResponse } from 'next/server'
import Pusher from 'pusher'
import { auth } from '@/auth'

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.PUSHER_CLUSTER!,
})

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const formData = await request.formData()
  const socketId = formData.get('socket_id') as string
  const channel = formData.get('channel_name') as string

  const authResponse = pusher.authorizeChannel(socketId, channel, {
    user_id: session.user.id,
    user_info: { name: session.user.name },
  })

  return NextResponse.json(authResponse)
}
// Triggering an event from server code
await pusher.trigger('private-chat', 'message', {
  text: 'Hello',
  userId: session.user.id,
})

Pusher and Ably offload the WebSocket infrastructure entirely. You call an API to trigger events and subscribe on the client. The cost is a third-party dependency and usage fees.

Option 2: A Separate WebSocket Server

For full control, run a standalone Node.js WebSocket server (using the ws library) deployed separately from the Next.js application, communicating via internal APIs.

Option 3: SSE + Server Actions Combined

For "real-time form" scenarios โ€” where the user submits an action and wants to watch server-side progress in real time โ€” SSE combined with Server Actions is an elegant pure-Next.js pattern:

// Client-side
'use client'

import { useState } from 'react'
import { useSSE } from '@/hooks/useSSE'
import { startProcessing } from './actions'

export function ProcessForm() {
  const [taskId, setTaskId] = useState<string | null>(null)
  const [progress, setProgress] = useState(0)
  const [done, setDone] = useState(false)

  useSSE({
    url: taskId ? `/api/task-progress/${taskId}` : '',
    enabled: !!taskId && !done,
    onMessage: (data: { type: string; percent: number }) => {
      if (data.type === 'progress') setProgress(data.percent)
      if (data.type === 'complete') setDone(true)
    },
  })

  async function handleSubmit() {
    const id = await startProcessing()
    setTaskId(id)
  }

  return (
    <div>
      <button onClick={handleSubmit}>Start Processing</button>
      {taskId && (
        <div>
          <div>Progress: {progress}%</div>
          {done && <div>Complete!</div>}
        </div>
      )}
    </div>
  )
}
// app/actions.ts
'use server'
export async function startProcessing(): Promise<string> {
  const taskId = crypto.randomUUID()
  // Store taskId in database or Redis for the progress API to read
  // Kick off background processing...
  return taskId
}

SSE is the right default for real-time features in Next.js: natively supported, protocol simplicity, automatic client reconnection, and standard HTTP compatibility (firewall and proxy friendly). Only reach for WebSocket โ€” or a managed service like Pusher โ€” when you genuinely need frequent bidirectional communication that cannot be modeled as server-push plus separate client HTTP requests.

Rate this chapter
4.6  / 5  (5 ratings)

๐Ÿ’ฌ Comments