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 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:
- Bidirectional communication: This allows data to flow in both directions, enabling real-time exchange between the client and server.
- Full-duplex protocol: Both the client and server can send and receive data simultaneously without waiting for the other.
- Maintains Persistent Connection: Keeps the connection open between the client and server, avoiding repeated handshakes and improving efficiency for continuous data exchange.
- Supports Binary Data Transmission: Enables the transmission of non-text data, such as images, audio, or files, in addition to standard text formats.
- 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)
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:
- Unidirectional Communication: The server can send updates to the client, but the client cannot send data back through the same connection.
- Uses Standard HTTP: Operates over regular HTTP connections, making it compatible with most web servers and firewalls.
- Automatic Reconnection: Built-in mechanism to automatically re-establish the connection if it is interrupted.
- Text-Based Data Only: Transmits only text-based data, such as JSON or plain text, rather than binary formats.
- 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.