Full Stack
Aug 2026•11 min read

Building Realtime Applications with Next.js & WebSockets

Architecting bidirectional event streams with zero server overhead and horizontal scaling

AP

Ashish Pal

Full Stack Developer

Building Realtime Applications with Next.js & WebSockets

Executive Takeaways & Key Concepts

  • Decouple Next.js serverless HTTP rendering from stateful WebSocket connection gateways.
  • Use Redis Pub/Sub channels to broadcast state changes across horizontally scaled containers.
  • Implement exponential backoff and heartbeat ping/pong to survive network drops without flooding servers.
  • Leverage binary payloads (MessagePack/Protobuf) for high-frequency telemetry data streams.

1. The Serverless WebSocket Conundrum

Why traditional serverless runtimes fail with long-lived TCP connections

Building real-time features like live collaboration, instant chat, or stock feeds in Next.js poses an architectural dilemma: modern Next.js deployments are optimized for ephemeral, stateless serverless lambda execution. However, WebSockets require persistent, stateful TCP handshakes that remain alive for minutes or hours.

When deploying to Vercel, AWS Lambda, or Cloudflare Workers, incoming HTTP requests spin up and tear down execution contexts within milliseconds. Forcing WebSocket lifecycles into serverless lambdas results in connection termination timeouts, massive cold starts, and excessive billing.

The solution adopted by high-scale production teams is a decoupled hybrid topology: Next.js serves the frontend UI, React Server Components (RSC), and standard REST/GraphQL APIs, while a lightweight, clustered WebSocket gateway manages persistent bidirectional streams.

Architecture Rule: Never attempt to bind long-lived TCP sockets directly inside Next.js API Routes hosted on serverless platforms. Always terminate WebSocket connections on a dedicated stateful node cluster or managed edge broker.

2. Setting Up Clustered Redis Pub/Sub Broadcasting

Routing events between decoupled gateway instances

In a production system with multiple WebSocket gateway instances behind an Elastic Load Balancer (ALB), Client A may be connected to Gateway Instance #1, while Client B is connected to Gateway Instance #2. If Client A broadcasts an update, Gateway #1 must propagate that payload to Gateway #2 in sub-5ms latency.

We use a Redis Pub/Sub bus (or Redis Streams for guaranteed at-least-once delivery) as the central backbone. Each gateway subscribes to relevant tenant channels upon connection and distributes incoming payloads to matching local client sockets.

gateway/realtime-cluster.ts
typescript
import { createClient } from "redis";
import { WebSocketServer, WebSocket } from "ws";
import http from "http";

interface RealtimeMessage {
  roomId: string;
  senderId: string;
  type: "CHAT_MESSAGE" | "CURSOR_MOVE" | "STATE_SYNC";
  payload: Record<string, unknown>;
  timestamp: number;
}

const server = http.createServer();
const wss = new WebSocketServer({ server });

// Redis connection pairs for Pub & Sub
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

const roomSubscribers = new Map<string, Set<WebSocket>>();

export async function bootstrapCluster() {
  await Promise.all([pubClient.connect(), subClient.connect()]);

  // Subscribe to central broadcast channel
  await subClient.subscribe("global-realtime-events", (rawMessage) => {
    const data: RealtimeMessage = JSON.parse(rawMessage);
    const clientsInRoom = roomSubscribers.get(data.roomId);

    if (clientsInRoom) {
      const payloadString = JSON.stringify(data);
      clientsInRoom.forEach((socket) => {
        if (socket.readyState === WebSocket.OPEN) {
          socket.send(payloadString);
        }
      });
    }
  });

  wss.on("connection", (socket, req) => {
    let currentRoomId = "general";

    socket.on("message", async (data) => {
      try {
        const parsed: RealtimeMessage = JSON.parse(data.toString());
        currentRoomId = parsed.roomId;

        // Track room membership
        if (!roomSubscribers.has(currentRoomId)) {
          roomSubscribers.set(currentRoomId, new Set());
        }
        roomSubscribers.get(currentRoomId)!.add(socket);

        // Publish to Redis broker for multi-instance broadcast
        await pubClient.publish("global-realtime-events", JSON.stringify(parsed));
      } catch (err) {
        console.error("Malformed payload received:", err);
      }
    });

    socket.on("close", () => {
      roomSubscribers.get(currentRoomId)?.delete(socket);
    });
  });

  server.listen(8080, () => console.log("WebSocket cluster listening on :8080"));
}

3. Heartbeats, Zombie Connections & Memory Leaks

Managing dead TCP connections with robust ping/pong intervals

Network interfaces on mobile devices frequently switch between Wi-Fi and 5G, or sleep during screen locks without sending a TCP FIN packet. This leaves 'zombie sockets' open on the server, rapidly consuming file descriptors and memory.

A production WebSocket gateway must implement periodic ping/pong health checks. If a client fails to reply to a ping frame within a 30-second window, the server forcefully terminates the socket and releases memory allocations.

  • Set ping interval to 30,000ms with a strict 10,000ms response deadline.
  • Attach an isAlive boolean flag to the WebSocket instance that resets on each 'pong' event.
  • Clean up any event listeners and intervals when the socket emits 'close' or 'error' to avoid GC leaks.

4. Client-Side React 19 Custom Hook with Exponential Backoff

Building zero-flicker resilience in the frontend Next.js application

On the Next.js client, reconnecting naively on disconnection will cause a 'thundering herd' problem during server restarts. Implementing jittered exponential backoff guarantees that thousands of clients don't flood the gateway simultaneously.

hooks/useRealtimeSocket.ts
typescript
import { useEffect, useRef, useState, useCallback } from "react";

interface SocketOptions {
  url: string;
  roomId: string;
  maxRetries?: number;
}

export function useRealtimeSocket<T>({ url, roomId, maxRetries = 10 }: SocketOptions) {
  const [lastMessage, setLastMessage] = useState<T | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const socketRef = useRef<WebSocket | null>(null);
  const retryCount = useRef(0);

  const connect = useCallback(() => {
    const ws = new WebSocket(url);
    socketRef.current = ws;

    ws.onopen = () => {
      setIsConnected(true);
      retryCount.current = 0; // Reset retries on successful connection
    };

    ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        setLastMessage(data);
      } catch (err) {
        console.error("Failed to parse websocket frame:", err);
      }
    };

    ws.onclose = () => {
      setIsConnected(false);
      if (retryCount.current < maxRetries) {
        // Exponential backoff with jitter: 2^retry * 1000ms + random jitter
        const delay = Math.pow(2, retryCount.current) * 1000 + Math.random() * 1000;
        retryCount.current += 1;
        setTimeout(connect, Math.min(delay, 30000));
      }
    };

    ws.onerror = () => ws.close();
  }, [url, maxRetries]);

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

  const sendMessage = useCallback((payload: unknown) => {
    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(JSON.stringify({ roomId, payload, timestamp: Date.now() }));
    }
  }, [roomId]);

  return { isConnected, lastMessage, sendMessage };
}

5. Production Telemetry & Performance Benchmarks

What to monitor when running 50,000+ concurrent connections

When scaling WebSocket infrastructure to tens of thousands of simultaneous users, monitoring standard CPU and memory is insufficient. Key operational metrics include:

  • Active TCP File Descriptors (`ulimit -n` must be increased to 65,535+ in Linux).
  • Event Loop Latency: Keep Node.js libuv event loop lag strictly under 20ms.
  • Redis Pub/Sub Latency: Alert if publish-to-subscriber latency exceeds 15ms.
  • Message Serialization Overhead: Transition from JSON.stringify to binary Protocol Buffers if throughput exceeds 10,000 msgs/sec.
#Next.js#WebSockets#Node.js#Redis Pub/Sub#Full Stack#Distributed Systems

More Engineering Notes

View All
Connect