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 connectionsAdditionally, 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
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.
Always implement reconnection logic. WebSocket connections drop. Exponential backoff prevents hammering the server during outages.
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.
Track connected clients. Maintain a Map of user IDs to WebSocket instances for targeted message delivery.
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.