architecture2024-04-10·12 min·313/348

Maintaining WebSocket Connections in Next.js Applications

How to implement and maintain WebSocket connections in Next.js App Router with proper error handling and reconnection

Maintaining WebSocket Connections in Next.js Applications

Introduction

Next.js is primarily designed for HTTP request/response cycles, but many modern applications need real-time capabilities. WebSocket connections provide bidirectional communication between client and server, enabling features like live chat, real-time dashboards, collaborative editing, and live notifications.

The challenge in Next.js is that the default serverless deployment model (Vercel, Netlify) does not support long-lived WebSocket connections. This post covers how to implement WebSockets in Next.js across different deployment scenarios.

Environment

  • OS: Windows 11
  • Node.js: v20.10.0
  • Next.js: 14.0.4
  • WebSocket: ws 8.16.0
  • Deployment: Custom Node.js server + Vercel

Problem

WebSockets do not work in the standard Next.js deployment model:

// This fails on serverless deployments
// WebSocket connections require persistent server processes
const ws = new WebSocket('wss://api.example.com/ws')
// Error: WebSocket connection failed
// Serverless functions cannot maintain long-lived connections

Additionally, in custom server setups, the WebSocket server must be integrated with the Next.js server without interfering with HTTP routes.

Solution

Approach 1: Custom Next.js server with WebSocket

// server.ts
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import { WebSocketServer } from 'ws'

const dev = process.env.NODE_ENV !== 'production'
const hostname = 'localhost'
const port = parseInt(process.env.PORT || '3000', 10)

const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()

app.prepare().then(() => {
  const server = createServer(async (req, res) => {
    const parsedUrl = parse(req.url!, true)
    await handle(req, res, parsedUrl)
  })

  // Create WebSocket server attached to HTTP server
  const wss = new WebSocketServer({ server, path: '/ws' })

  const clients = new Map>()

  wss.on('connection', (ws, req) => {
    const userId = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('userId')
    
    if (!userId) {
      ws.close(1008, 'Missing userId')
      return
    }

    // Add client to tracked connections
    if (!clients.has(userId)) {
      clients.set(userId, new Set())
    }
    clients.get(userId)!.add(ws)

    console.log(`User ${userId} connected. Total: ${wss.clients.size}`)

    ws.on('message', (data) => {
      try {
        const message = JSON.parse(data.toString())
        handleMessage(userId, message, ws)
      } catch (error) {
        ws.send(JSON.stringify({ error: 'Invalid message format' }))
      }
    })

    ws.on('close', () => {
      clients.get(userId)?.delete(ws)
      if (clients.get(userId)?.size === 0) {
        clients.delete(userId)
      }
      console.log(`User ${userId} disconnected. Total: ${wss.clients.size}`)
    })

    ws.on('error', (error) => {
      console.error(`WebSocket error for user ${userId}:`, error)
    })

    // Send welcome message
    ws.send(JSON.stringify({ type: 'connected', userId }))
  })

  function handleMessage(userId: string, message: any, sender: any) {
    switch (message.type) {
      case 'chat':
        // Broadcast to all clients except sender
        wss.clients.forEach((client) => {
          if (client.readyState === 1 && client !== sender) {
            client.send(JSON.stringify({
              type: 'chat',
              userId,
              content: message.content,
              timestamp: Date.now(),
            }))
          }
        })
        break

      case 'typing':
        // Broadcast typing indicator
        wss.clients.forEach((client) => {
          if (client.readyState === 1 && client !== sender) {
            client.send(JSON.stringify({
              type: 'typing',
              userId,
            }))
          }
        })
        break
    }
  }

  server.listen(port, () => {
    console.log(`> Ready on http://${hostname}:${port}`)
    console.log(`> WebSocket ready on ws://${hostname}:${port}/ws`)
  })
})

Approach 2: Client-side WebSocket hook with reconnection

// hooks/useWebSocket.ts
'use client'

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

interface UseWebSocketOptions {
  url: string
  userId: string
  onMessage?: (data: any) => void
  onConnect?: () => void
  onDisconnect?: () => void
  reconnectAttempts?: number
  reconnectInterval?: number
}

export function useWebSocket({
  url,
  userId,
  onMessage,
  onConnect,
  onDisconnect,
  reconnectAttempts = 5,
  reconnectInterval = 3000,
}: UseWebSocketOptions) {
  const ws = useRef(null)
  const reconnectCount = useRef(0)
  const [isConnected, setIsConnected] = useState(false)
  const [lastMessage, setLastMessage] = useState(null)

  const connect = useCallback(() => {
    const wsUrl = `${url}?userId=${userId}`
    ws.current = new WebSocket(wsUrl)

    ws.current.onopen = () => {
      setIsConnected(true)
      reconnectCount.current = 0
      onConnect?.()
    }

    ws.current.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data)
        setLastMessage(data)
        onMessage?.(data)
      } catch (error) {
        console.error('Failed to parse WebSocket message:', error)
      }
    }

    ws.current.onclose = (event) => {
      setIsConnected(false)
      onDisconnect?.()

      // Attempt reconnection
      if (reconnectCount.current < reconnectAttempts) {
        setTimeout(() => {
          reconnectCount.current++
          connect()
        }, reconnectInterval * Math.pow(2, reconnectCount.current)) // Exponential backoff
      }
    }

    ws.current.onerror = (error) => {
      console.error('WebSocket error:', error)
    }
  }, [url, userId, onMessage, onConnect, onDisconnect, reconnectAttempts, reconnectInterval])

  useEffect(() => {
    connect()
    return () => {
      ws.current?.close()
    }
  }, [connect])

  const send = useCallback((message: any) => {
    if (ws.current?.readyState === WebSocket.OPEN) {
      ws.current.send(JSON.stringify(message))
    }
  }, [])

  return { isConnected, lastMessage, send }
}

Approach 3: External WebSocket service

// lib/pusher.ts
import Pusher from 'pusher-js'

export const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  forceTLS: true,
})

export function subscribeToChannel(channel: string) {
  return pusher.subscribe(channel)
}
// hooks/usePusher.ts
'use client'

import { useEffect, useState } from 'react'
import { pusher, subscribeToChannel } from '@/lib/pusher'

export function usePusher(channel: string, event: string) {
  const [data, setData] = useState(null)

  useEffect(() => {
    const channelInstance = subscribeToChannel(channel)

    channelInstance.bind(event, (newData: any) => {
      setData(newData)
    })

    return () => {
      channelInstance.unbind_all()
      channelInstance.unsubscribe()
    }
  }, [channel, event])

  return data
}

Lessons Learned

  1. Serverless deployments do not support WebSockets. Use a custom Node.js server, or external services like Pusher, Ably, or Socket.io for real-time features on Vercel/Netlify.

  2. Always implement reconnection logic. WebSocket connections drop. Exponential backoff prevents hammering the server during outages.

  3. Handle authentication at connection time. Pass tokens in the WebSocket URL or use the first message for authentication. Do not rely on cookies alone for WebSocket auth.

  4. Track connected clients. Maintain a Map of user IDs to WebSocket instances for targeted message delivery.

  5. Use heartbeats to detect dead connections:

// Server-side heartbeat
setInterval(() => {
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.ping()
    }
  })
}, 30000)

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.