paint-brush
Streaming in Next.js 15: WebSockets vs Server-Sent Eventsby@felixiho

Streaming in Next.js 15: WebSockets vs Server-Sent Events

by Felix OkekeJanuary 9th, 2025
Read on Terminal Reader
tldt arrow

Too Long; Didn't Read

Next.js 15 provides robust support for implementing both WebSockets and SSE. WebS sockets enable real-time, bidirectional communication between a client and a server. SSE is designed for scenarios where the server sends data without expecting responses from the client.
featured image - Streaming in Next.js 15: WebSockets vs Server-Sent Events
Felix Okeke HackerNoon profile picture


Real-time data streaming is essential for modern web applications, powering features like low-latency audio/visual streaming, stock updates, collaborative tools, and live geolocation. Next.js provides robust support for implementing both WebSockets and Server-Sent Events (SSE), making it an excellent choice for building scalable real-time solutions. In this guide, we’ll explore these technologies, compare their strengths and weaknesses, and outline practical implementation strategies for integrating them into your Next.js applications.


Understanding the Basics

Before diving into implementations, let’s clarify the key differences between WebSockets and SSE:

WebSockets

Websockets Architecture


WebSockets are a computer communications protocol that enable real-time, bidirectional communication between a client and a server over a single Transmission Control Protocol (TCP) connection.

Key Features of WebSockets:

  1. Bidirectional communication: This allows data to flow in both directions, enabling real-time exchange between the client and server.
  2. Full-duplex protocol: Both the client and server can send and receive data simultaneously without waiting for the other.
  3. Maintains Persistent Connection: Keeps the connection open between the client and server, avoiding repeated handshakes and improving efficiency for continuous data exchange.
  4. Supports Binary Data Transmission: Enables the transmission of non-text data, such as images, audio, or files, in addition to standard text formats.
  5. Higher Overhead but Lower Latency: Involves more resource consumption to maintain a connection but ensures faster data delivery due to reduced delays.


Server-Sent Events (SSE)

Architecture diagram for SSE


Server-Sent Events (SSE) is a unidirectional communication protocol that allows servers to push real-time updates to clients over a single HTTP connection. Unlike WebSockets, SSE is designed for scenarios where the server continuously sends data without expecting responses from the client.


Key Features of Server Sent Events:

  1. Unidirectional Communication: The server can send updates to the client, but the client cannot send data back through the same connection.
  2. Uses Standard HTTP: Operates over regular HTTP connections, making it compatible with most web servers and firewalls.
  3. Automatic Reconnection: Built-in mechanism to automatically re-establish the connection if it is interrupted.
  4. Text-Based Data Only: Transmits only text-based data, such as JSON or plain text, rather than binary formats.
  5. Lower Overhead but Slightly Higher Latency: Consumes fewer resources but may have slightly delayed delivery compared to some bidirectional protocols.


Implementation in Next.js 15

Let’s explore how to implement both approaches in a Next.js 15 application.


WebSocket Implementation

Next.Js API routes and Route handlers are for serverless functions which mean they do not support websocket servers.

For this guide, we’ll implement a simple WebSocket server that emits messages to connected clients. If you don't have one, you can quickly create a server using Node.js on your local machine as shown below:

const express = require("express");
const http = require("http");
const WebSocket = require("ws");

const app = express();

// Create an HTTP server
const server = http.createServer(app);

// Create a WebSocket server
const wss = new WebSocket.Server({ server, path: "/ws" });

// WebSocket connection handling
wss.on("connection", (ws) => {
  console.log("New WebSocket connection");

  // Send a welcome message to the client
  ws.send(
    JSON.stringify({ type: "welcome", message: "Connected to WebSocket API!" })
  );

  // Handle messages from the client
  ws.on("message", (message) => {
    console.log("Received:", message);

    // Broadcast the message to all connected clients
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({ type: "broadcast", data: message }));
      }
    });
  });

  // Handle disconnection
  ws.on("close", () => {
    console.log("WebSocket connection closed");
  });
});

// Start the HTTP server
const PORT = 3000;
server.listen(PORT, () => {
  console.log(`API server running at http://localhost:${PORT}`);
  console.log(`WebSocket endpoint available at ws://localhost:${PORT}/ws`);
});


Now create a hook in your Next.js codebase called useWebsocket.ts

import { useEffect, useRef, useState } from "react";

interface UseWebSocketOptions {
  onOpen?: (event: Event) => void;
  onMessage?: (event: MessageEvent) => void;
  onClose?: (event: CloseEvent) => void;
  onError?: (event: Event) => void;
  reconnectAttempts?: number;
  reconnectInterval?: number;
}

export const useWebSocket = (
  url: string,
  options: UseWebSocketOptions = {}
) => {
  const {
    onOpen,
    onMessage,
    onClose,
    onError,
    reconnectAttempts = 5,
    reconnectInterval = 3000,
  } = options;

  const [isConnected, setIsConnected] = useState(false);
  const [isReconnecting, setIsReconnecting] = useState(false);

  const webSocketRef = useRef<WebSocket | null>(null);
  const attemptsRef = useRef(0);

  const connectWebSocket = () => {
    setIsReconnecting(false);
    attemptsRef.current = 0;

    const ws = new WebSocket(url);
    webSocketRef.current = ws;

    ws.onopen = (event) => {
      setIsConnected(true);
      setIsReconnecting(false);
      if (onOpen) onOpen(event);
    };

    ws.onmessage = (event) => {
      if (onMessage) onMessage(event);
    };

    ws.onclose = (event) => {
      setIsConnected(false);
      if (onClose) onClose(event);

      // Attempt reconnection if allowed
      if (attemptsRef.current < reconnectAttempts) {
        setIsReconnecting(true);
        attemptsRef.current++;
        setTimeout(connectWebSocket, reconnectInterval);
      }
    };

    ws.onerror = (event) => {
      if (onError) onError(event);
    };
  };

  useEffect(() => {
    connectWebSocket();

    // Cleanup on component unmount
    return () => {
      if (webSocketRef.current) {
        webSocketRef.current.close();
      }
    };
  }, [url]);

  const sendMessage = (message: string) => {
    if (
      webSocketRef.current &&
      webSocketRef.current.readyState === WebSocket.OPEN
    ) {
      webSocketRef.current.send(message);
    } else {
      console.error("WebSocket is not open. Unable to send message.");
    }
  };

  return { isConnected, isReconnecting, sendMessage };
};


This hook returns two variables to track the WebSocket's state and a sendMessage function for sending messages to the WebSocket server. By using this hook, you simplify the process of consuming data from the WebSocket server, as it handles connection management and data processing. This approach makes your code more modular and easier to maintain.


For a working Next.js example, please check the repository here


Server-Sent Events Implementation

In this implementation, we’ll be creating a route handler to process initiate the request with a server that streams events back as responses.


const stream = new ReadableStream({
      async start(controller) {
        try {
          const response = await fetch(`${URL}/api/sse`, {
            headers: {
              Authorization: "Bearer token",
              "Cache-Control": "no-cache",
            },
          });

          if (!response.ok) {
            const errorBody = await response.text();
            console.error("API error message:", errorBody);
            controller.enqueue(
              encodeSSE("error", `API responded with status ${response.status}`)
            );
            controller.close();
            return;
          }

          const reader = response.body?.getReader();
          if (!reader) {
            controller.enqueue(encodeSSE("error", "No data received from API"));
            controller.close();
            return;
          }

          // Notify client of successful connection
          controller.enqueue(encodeSSE("init", "Connecting..."));

          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            controller.enqueue(value);
          }

          controller.close();
          reader.releaseLock();
        } catch (error) {
          console.error("Stream error:", error);
          controller.enqueue(encodeSSE("error", "Stream interrupted"));
          controller.close();
        }
      },
    });

    return new NextResponse(stream, {
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
        "Content-Type": "text/event-stream",
      },
      status: 200,
    });


Next is to create a hook to handle the streaming responses and update the UI.


const useSSE = (url: string) => {
  const [isConnected, setIsConnected] = useState(false);
  const [messages, setMessages] = useState<any[]>([]); // Array to store messages
  const [error, setError] = useState<string | null>(null);
  const eventSourceRef = useRef<EventSource | null>(null);
  const reconnectAttemptsRef = useRef(0);
  const maxReconnectAttempts = 5;

  const connect = () => {
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
    }

    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      setIsConnected(true);
      setError(null);
      reconnectAttemptsRef.current = 0;
    };

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        setMessages((prev) => [...prev, data]); // Append new message to the array
      } catch (err) {
        console.error("Failed to parse message:", err);
      }
    };

    eventSource.onerror = () => {
      setIsConnected(false);
      setError("Connection lost, attempting to reconnect...");
      eventSource.close();
      handleReconnect();
    };
  };

  const handleReconnect = () => {
    if (reconnectAttemptsRef.current < maxReconnectAttempts) {
      const retryTimeout = 1000 * Math.pow(2, reconnectAttemptsRef.current); // Exponential backoff
      setTimeout(() => {
        reconnectAttemptsRef.current += 1;
        connect();
      }, retryTimeout);
    } else {
      setError("Maximum reconnect attempts reached.");
    }
  };

  useEffect(() => {
    connect();

    return () => {
      eventSourceRef.current?.close(); // Clean up connection on unmount
    };
  }, [url]);

  return { isConnected, messages, error };
};


This hook provides an easy way to manage a Server-Sent Events (SSE) connection within a React functional component. It is responsible for establishing a persistent connection, tracking connection state, handling incoming messages, handling reconnection logic and error management.


For a working Next.js example, please check the repository here.


Performance Considerations

1. Connection Management:

For managing multiple WebSocket connections (a "connection pool"), you can create a pool manager to open, reuse, and close connections as needed.

class WebSocketPool {
    private pool: Map<string, WebSocket> = new Map();

    connect(url: string): WebSocket {
        if (this.pool.has(url)) {
            return this.pool.get(url)!;
        }

        const ws = new WebSocket(url);
        this.pool.set(url, ws);

        ws.onclose = () => {
            console.log(`Connection to ${url} closed.`);
            this.pool.delete(url);
        };

        return ws;
    }

    sendMessage(url: string, message: string) {
        const ws = this.pool.get(url);
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(message);
        } else {
            console.error(`WebSocket to ${url} is not open.`);
        }
    }

    closeConnection(url: string) {
        const ws = this.pool.get(url);
        if (ws) {
            ws.close();
            this.pool.delete(url);
        }
    }

    closeAll() {
        this.pool.forEach((ws) => ws.close());
        this.pool.clear();
    }
}

export const webSocketPool = new WebSocketPool();


use the connection pool

import { webSocketPool } from '../utils/webSocketPool';

const ws1 = webSocketPool.connect('ws://localhost:3000/ws1');
const ws2 = webSocketPool.connect('ws://localhost:3000/ws2');

webSocketPool.sendMessage('ws://localhost:3000/ws1', 'Hello WS1');
webSocketPool.sendMessage('ws://localhost:3000/ws2', 'Hello WS2');

// Close individual connection
webSocketPool.closeConnection('ws://localhost:3000/ws1');

// Close all connections
webSocketPool.closeAll();


The WebSocketPool class manages WebSocket connections by storing them in a Map, using their URLs as keys. When a connection is requested, it reuses an existing WebSocket if available or creates a new one and adds it to the pool. Messages can be sent through open connections using sendMessage. The closeConnection method removes and closes a specific WebSocket, while closeAll shuts down and clears all connections, ensuring efficient management and reuse of resources.


2. Memory Management

To prevent leaks, we can create a custom hook that monitors memory usage and triggers cleanup actions when memory usage exceeds a specified threshold. Here's how you can adapt it:


import { useEffect } from "react";

const useMemoryManager = (onHighMemory: () => void, interval = 5000, threshold = 0.8) => {
  useEffect(() => {
    const monitorMemory = () => {
      const memoryUsage = process.memoryUsage();
      const heapUsedRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;

      if (heapUsedRatio > threshold) {
        onHighMemory();
      }
    };

    const intervalId = setInterval(monitorMemory, interval);

    return () => {
      clearInterval(intervalId); // Cleanup interval on unmount
    };
  }, [onHighMemory, interval, threshold]);
};

export default useMemoryManager;


The useMemoryManager hook provides a way to monitor heap memory usage within the app and trigger cleanup actions when memory usage exceeds a specified threshold. It accepts three parameters: a callback function (onHighMemory) that is executed when high memory usage is detected, an interval duration (defaulting to 5000 milliseconds) for periodic checks, and a memory threshold (defaulting to 80% of heap memory).


The hook utilizes setInterval to repeatedly assess memory usage via process.memoryUsage() and compares the ratio of heapUsed to heapTotal against the threshold. If the threshold is exceeded, the onHighMemory callback is invoked, allowing developers to implement cleanup strategies such as closing idle connections, clearing caches, or triggering garbage collection.


Additionally, the hook ensures proper resource management by clearing the interval when the component unmounts. This makes it a practical solution for maintaining efficient memory usage and avoiding potential memory leaks in server-side or Electron-based React applications.



Choosing the Right Approach

WebSockets

Server Sent Events

Use when you need Bidirectional communication

Use when you only need server-to-client updates

Use when you need Real-time updates are critical

Use when you want simpler implementation

Building Chat applications

Building News feeds

Building Collaborative editing tools

Building social media streams

Building Real-time games

Building dashboard updates

Building Live trading platforms

Building status monitoring



Conclusion

Both WebSockets and SSE have their place in modern web applications. WebSockets excel in scenarios requiring bidirectional communication and low latency, while SSE is perfect for simpler, unidirectional streaming needs. The choice between them should be based on your specific use case, considering factors like:


  • Communication pattern requirements

  • Scalability needs

  • Browser support requirements

  • Development complexity tolerance

  • Infrastructure constraints


Remember that these technologies aren't mutually exclusive – many applications can benefit from using both, each serving different purposes within the same system.