|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/real-time
$cat docs/real-time-&-streaming-patterns-in-next.js.md
updated Last weekยท30 min readยทpublished

Real-time & Streaming Patterns in Next.js

โ—†Next.jsโ—†Real-timeโ—†Streamingโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

Real-time applications feel alive. They show live notifications, collaborative cursors, streaming logs, and dashboards that update without a page refresh. Next.js gives you several ways to build this experience, but the right choice depends on latency, directionality, and whether the work happens on the server, the client, or both.

The App Router is fundamentally built around streaming. React Server Components, Suspense boundaries, and loading UI are all designed to let the server emit HTML progressively as data becomes ready. This is a form of real-time delivery, even if it is not bidirectional. For true two-way communication, you typically look outside the Next.js runtime to WebSockets, hosted real-time services, or carefully designed Server-Sent Events.

This guide covers the full landscape: why route handlers cannot hold long-lived WebSocket connections, how to push updates from the server with SSE, how to stream React Server Components, how Partial Prerendering gives you a static shell with dynamic holes, and how to structure clients so connections are shared, reconnected, and cleaned up correctly. The goal is a production mental model: pick the right primitive, keep the server stateless, and never leak connection lifecycle into the client without a plan.

WebSockets Architecture

WebSockets are the classic answer to real-time, bidirectional messaging. They keep a single TCP connection open and allow either side to push messages at any time. They are ideal for chat, collaborative editing, multiplayer games, and live operational dashboards where the client needs to send data as well as receive it.

Next.js route handlers cannot hold long-lived WebSocket connections. A route handler is a short-lived, stateless request/response function. When the response is sent, the function is done and the underlying server may recycle the process. There is no persistent socket object to keep alive. If you try to wedge a WebSocket server into a route handler, you will lose connections on every deployment, function timeout, or cold start.

The standard pattern is to run the WebSocket server outside the Next.js runtime. Options include Socket.io with a dedicated Node.js server, PartyKit on Cloudflare Workers, Ably, Pusher, or a custom WebSocket server behind a load balancer. The Next.js application connects to this external service as a client. This keeps the Next.js app stateless and scalable while the real-time service owns the connection state.

socket-client.tsx
TSX
1// lib/socket.ts
2import { io, Socket } from "socket.io-client";
3
4let socket: Socket | null = null;
5
6export function getSocket() {
7 if (!socket) {
8 socket = io(process.env.NEXT_PUBLIC_SOCKET_URL!, {
9 transports: ["websocket"],
10 autoConnect: false,
11 reconnection: true,
12 reconnectionAttempts: 5,
13 reconnectionDelay: 1000,
14 });
15 }
16 return socket;
17}
18
19// components/ChatClient.tsx
20"use client";
21
22import { useEffect, useState } from "react";
23import { getSocket } from "@/lib/socket";
24
25export default function ChatClient({ roomId }: { roomId: string }) {
26 const [messages, setMessages] = useState<string[]>([]);
27 const [input, setInput] = useState("");
28
29 useEffect(() => {
30 const socket = getSocket();
31 socket.connect();
32 socket.emit("join", roomId);
33 socket.on("message", (msg: string) => {
34 setMessages((prev) => [...prev, msg]);
35 });
36
37 return () => {
38 socket.off("message");
39 socket.emit("leave", roomId);
40 // Do not disconnect if the socket is shared across components
41 // socket.disconnect();
42 };
43 }, [roomId]);
44
45 const send = () => {
46 const socket = getSocket();
47 socket.emit("message", { roomId, text: input });
48 setInput("");
49 };
50
51 return (
52 <div>
53 <ul>
54 {messages.map((m, i) => (
55 <li key={i}>{m}</li>
56 ))}
57 </ul>
58 <input value={input} onChange={(e) => setInput(e.target.value)} />
59 <button onClick={send}>Send</button>
60 </div>
61 );
62}

The example above keeps a single socket instance in module scope. This is important because multiple components on the same page should share one connection, not create a new connection per component. The trade-off is that cleanup becomes more careful: you unsubscribe from events but avoid disconnecting the socket unless the entire application is unmounting.

โš 

warning

Never run a WebSocket server inside a Next.js route handler or Server Action. Use a dedicated real-time service, a separate Node.js process, or a platform that hosts persistent connections. The Next.js runtime is designed for request/response, not long-lived sockets.
Server-Sent Events (SSE)

Server-Sent Events are a one-way push mechanism from server to client. They use ordinary HTTP, reconnection is built into the browser, and the protocol is text-based. SSE is perfect for notifications, log streams, progress bars, and live dashboards where the server does all the talking and the client only listens.

In a Next.js route handler, you implement SSE by returning a ReadableStream with the correct headers. The client uses the browser's EventSource API to connect. Unlike WebSockets, SSE is unidirectional and does not require a separate protocol upgrade, so it works well through proxies and load balancers.

route.ts
TSX
1// app/api/events/route.ts
2import { NextRequest } from "next/server";
3
4export const dynamic = "force-dynamic";
5
6export async function GET(request: NextRequest) {
7 const encoder = new TextEncoder();
8 let interval: NodeJS.Timeout | null = null;
9 let count = 0;
10
11 const stream = new ReadableStream({
12 start(controller) {
13 controller.enqueue(encoder.encode("event: connected\ndata: connected\n\n"));
14
15 interval = setInterval(() => {
16 count += 1;
17 const payload = JSON.stringify({ time: Date.now(), count });
18 controller.enqueue(
19 encoder.encode(`event: tick\ndata: ${payload}\n\n`)
20 );
21 }, 1000);
22 },
23 cancel() {
24 if (interval) clearInterval(interval);
25 },
26 });
27
28 return new Response(stream, {
29 headers: {
30 "Content-Type": "text/event-stream",
31 "Cache-Control": "no-cache, no-transform",
32 Connection: "keep-alive",
33 },
34 });
35}

The client side is a small hook built around EventSource. Always handle the error event, because the browser will automatically reconnect but you may need to re-establish authentication or context. The AbortController or a simple cleanup function lets you close the connection when the component unmounts.

useEventSource.tsx
TSX
1// hooks/useEventSource.ts
2"use client";
3
4import { useEffect, useRef, useState } from "react";
5
6export function useEventSource(url: string) {
7 const [lastEvent, setLastEvent] = useState<string>("");
8 const [connected, setConnected] = useState(false);
9 const sourceRef = useRef<EventSource | null>(null);
10
11 useEffect(() => {
12 const source = new EventSource(url);
13 sourceRef.current = source;
14
15 source.onopen = () => setConnected(true);
16 source.onerror = () => setConnected(false);
17 source.onmessage = (event) => {
18 setLastEvent(event.data);
19 };
20
21 return () => {
22 source.close();
23 sourceRef.current = null;
24 };
25 }, [url]);
26
27 return { lastEvent, connected };
28}
29
30// components/LogStream.tsx
31"use client";
32
33import { useEventSource } from "@/hooks/useEventSource";
34
35export default function LogStream() {
36 const { lastEvent, connected } = useEventSource("/api/events");
37
38 return (
39 <div>
40 <p>Status: {connected ? "connected" : "disconnected"}</p>
41 <pre>{lastEvent}</pre>
42 </div>
43 );
44}

One subtle issue with SSE is authentication. The browser EventSource does not support custom headers, so you cannot send a Bearer token directly. Workarounds include passing a short-lived token in the query string, using cookies with HttpOnly and SameSite, or falling back to a fetch-based streaming implementation with ReadableStream on the client.

โ„น

info

For authenticated SSE, prefer cookie-based session validation. If you must use query tokens, keep them short-lived and include the user ID in the signed payload. Never accept the connection solely based on a long-lived API key in the URL.
Streaming React Server Components

Streaming React Server Components is one of Next.js's most powerful features. Instead of waiting for every data dependency to resolve before sending HTML, the server streams the shell of the page immediately and fills in the slow parts as they finish. The user sees content faster, and the Time to First Byte stays low even when one part of the page is slow.

The mechanism is Suspense. When a Server Component is wrapped in <Suspense>, React can emit the surrounding HTML and leave a placeholder for the deferred work. The placeholder is replaced when the promise resolves. This is not a persistent real-time connection like a WebSocket, but it is a form of progressive delivery that dramatically improves perceived performance.

streaming-rsc.tsx
TSX
1// app/dashboard/page.tsx
2import { Suspense } from "react";
3import { WeatherCard, WeatherCardSkeleton } from "@/components/WeatherCard";
4import { RecentOrders, RecentOrdersSkeleton } from "@/components/RecentOrders";
5
6export default function DashboardPage() {
7 return (
8 <main>
9 <h1>Dashboard</h1>
10
11 <Suspense fallback={<WeatherCardSkeleton />}>
12 <WeatherCard />
13 </Suspense>
14
15 <Suspense fallback={<RecentOrdersSkeleton />}>
16 <RecentOrders />
17 </Suspense>
18 </main>
19 );
20}
21
22// components/WeatherCard.tsx
23async function fetchWeather() {
24 // Slow upstream API
25 await new Promise((resolve) => setTimeout(resolve, 2000));
26 return { temp: 22, condition: "Cloudy" };
27}
28
29export async function WeatherCard() {
30 const weather = await fetchWeather();
31 return (
32 <div>
33 <p>Temperature: {weather.temp}ยฐC</p>
34 <p>Condition: {weather.condition}</p>
35 </div>
36 );
37}
38
39export function WeatherCardSkeleton() {
40 return <div>Loading weather...</div>;
41}

You can nest Suspense boundaries to create a waterfall-free layout. The outer shell renders first, then each inner boundary resolves independently. This is especially useful for pages with many independent data sources: one slow query no longer blocks the rest of the page.

Next.js also provides a loading.tsx convention that wraps an entire route segment in a Suspense boundary. This is useful for coarse-grained loading states, but for fine-grained control, use explicit Suspense in your page components. The combination of route-level loading UI and component-level Suspense gives you a complete streaming strategy.

โœ“

best practice

Place Suspense boundaries around the work that is actually slow. Wrapping a fast query adds no benefit; leaving a slow query unwrapped blocks the entire stream. Profile each data dependency and wrap the outliers.
Partial Prerendering (PPR)

Partial Prerendering is an experimental Next.js feature that lets you build a static shell at build time and fill dynamic holes at request time. It combines the speed of static generation with the freshness of dynamic data. The shell is cached globally, while the dynamic holes stream in per request.

PPR is enabled in next.config.ts. Once enabled, any route that uses dynamic APIs such as cookies(), headers(), or uncached data fetches becomes a dynamic hole in an otherwise static page. The static parts are served from the edge cache; the dynamic parts are rendered on demand and streamed into the shell.

next.config.ts
TSX
1// next.config.ts
2import type { NextConfig } from "next";
3
4const nextConfig: NextConfig = {
5 experimental: {
6 ppr: true,
7 },
8};
9
10export default nextConfig;

A typical PPR page looks like a normal streaming page with Suspense. The difference is that at build time, Next.js prerenders the static portions and records where dynamic holes should be injected. The first request receives the static shell immediately, and the dynamic holes stream in as they resolve.

ppr-page.tsx
TSX
1// app/blog/[slug]/page.tsx
2import { Suspense } from "react";
3import { notFound } from "next/navigation";
4import { getPostStatic } from "@/lib/posts";
5import { Comments, CommentsSkeleton } from "@/components/Comments";
6
7export default async function BlogPost({
8 params,
9}: {
10 params: Promise<{ slug: string }>;
11}) {
12 const { slug } = await params;
13 const post = await getPostStatic(slug);
14 if (!post) notFound();
15
16 return (
17 <article>
18 <h1>{post.title}</h1>
19 <div dangerouslySetInnerHTML={{ __html: post.html }} />
20
21 <Suspense fallback={<CommentsSkeleton />}>
22 <Comments postId={slug} />
23 </Suspense>
24 </article>
25 );
26}
27
28// components/Comments.tsx
29async function fetchComments(postId: string) {
30 // Dynamic data; not cached, so it becomes a PPR hole
31 const res = await fetch(`https://api.example.com/comments/${postId}`, {
32 cache: "no-store",
33 });
34 return res.json();
35}
36
37export async function Comments({ postId }: { postId: string }) {
38 const comments = await fetchComments(postId);
39 return (
40 <ul>
41 {comments.map((comment: { id: string; text: string }) => (
42 <li key={comment.id}>{comment.text}</li>
43 ))}
44 </ul>
45 );
46}
47
48export function CommentsSkeleton() {
49 return <p>Loading comments...</p>;
50}

PPR has compatibility constraints. It does not work with every middleware pattern, and some dynamic APIs force the entire route to be dynamic if used outside a Suspense boundary. As of Next.js 16, PPR remains experimental. Enable it for routes where the static shell is large and the dynamic parts are small and well-isolated.

๐Ÿ“

note

PPR is a rendering strategy, not a real-time protocol. Pair it with client-side polling, SSE, or WebSockets when the dynamic holes need to update after the initial render.
Choosing a Real-time Primitive

The right primitive depends on the direction of data flow, latency requirements, and whether you need persistent state. WebSockets are not always better than SSE, and SSE is not always better than long polling. Start with the simplest mechanism that meets your latency budget and upgrade only when you have evidence it is needed.

PrimitiveDirectionLatencyBest ForTrade-off
WebSocketsBidirectionalVery lowChat, multiplayer, gaming, live collaborationRequires external server; harder to scale
SSEServer โ†’ clientLowNotifications, logs, progress, dashboardsNo client push; auth via cookies or query
Long pollingServer โ†’ clientMediumLegacy systems, strict proxiesHigh overhead, slower than SSE
Streaming RSCServer โ†’ clientFirst render onlyProgressive page loads, slow dataOne-shot delivery, not live updates
PPRServer โ†’ clientFirst render onlyStatic shell + dynamic contentExperimental; requires compatible patterns

For a chat application, WebSockets are the clear winner. For a stock ticker where the server pushes prices and the client never responds, SSE is simpler and cheaper. For a page that renders user-specific recommendations but is otherwise static, PPR is the best fit. For a simple progress indicator during a file upload, SSE over the upload route handler is often enough.

๐Ÿ”ฅ

pro tip

When you need both server push and client-to-server messaging, consider a hybrid: SSE for the server-to-client stream and regular HTTP POST or Server Actions for client-to-server updates. This avoids the operational complexity of WebSockets if your latency budget allows.
Client Patterns

Real-time connections are a client-side concern. They must be created, shared, reconnected, and destroyed correctly. A common mistake is to create a new connection inside every component that needs it. This multiplies server load and makes state synchronization harder. Instead, use a module-level singleton or a context that owns the connection.

A robust WebSocket hook handles connection state, reconnection, message buffering, and cleanup. Keep the hook small and compose it from a shared socket manager. The hook should expose the connection state so UI can show disconnected indicators or disable inputs when offline.

useSocket.tsx
TSX
1// hooks/useSocket.ts
2"use client";
3
4import { useEffect, useRef, useState, useCallback } from "react";
5import { getSocket } from "@/lib/socket";
6
7export function useSocket(event: string, handler: (data: unknown) => void) {
8 const [connected, setConnected] = useState(false);
9 const handlerRef = useRef(handler);
10
11 useEffect(() => {
12 handlerRef.current = handler;
13 }, [handler]);
14
15 useEffect(() => {
16 const socket = getSocket();
17
18 const onConnect = () => setConnected(true);
19 const onDisconnect = () => setConnected(false);
20 const onEvent = (data: unknown) => handlerRef.current(data);
21
22 socket.on("connect", onConnect);
23 socket.on("disconnect", onDisconnect);
24 socket.on(event, onEvent);
25
26 if (socket.connected) setConnected(true);
27 socket.connect();
28
29 return () => {
30 socket.off("connect", onConnect);
31 socket.off("disconnect", onDisconnect);
32 socket.off(event, onEvent);
33 };
34 }, [event]);
35
36 const send = useCallback((data: unknown) => {
37 const socket = getSocket();
38 if (socket.connected) {
39 socket.emit(event, data);
40 } else {
41 // Buffer until reconnect, or drop with a warning
42 console.warn("Socket disconnected; message dropped");
43 }
44 }, [event]);
45
46 return { connected, send };
47}

For SSE, the pattern is similar but simpler because the browser manages reconnection. Your main responsibility is to close the connection on unmount and to handle the error event gracefully. If you need custom headers, fall back to a fetch-based stream reader instead of EventSource.

useFetchStream.tsx
TSX
1// hooks/useFetchStream.ts
2"use client";
3
4import { useEffect, useRef, useState } from "react";
5
6export function useFetchStream(url: string, token: string) {
7 const [events, setEvents] = useState<unknown[]>([]);
8 const abortRef = useRef<AbortController | null>(null);
9
10 useEffect(() => {
11 const controller = new AbortController();
12 abortRef.current = controller;
13
14 async function read() {
15 try {
16 const res = await fetch(url, {
17 headers: { Authorization: `Bearer ${token}` },
18 signal: controller.signal,
19 });
20 if (!res.body) return;
21
22 const reader = res.body.getReader();
23 const decoder = new TextDecoder();
24 let buffer = "";
25
26 while (true) {
27 const { value, done } = await reader.read();
28 if (done) break;
29 buffer += decoder.decode(value, { stream: true });
30 const lines = buffer.split("\n");
31 buffer = lines.pop() ?? "";
32 for (const line of lines) {
33 if (line.startsWith("data: ")) {
34 const payload = line.slice(6);
35 if (payload === "[DONE]") return;
36 setEvents((prev) => [...prev, JSON.parse(payload)]);
37 }
38 }
39 }
40 } catch (err) {
41 if ((err as Error).name !== "AbortError") {
42 console.error("Stream error:", err);
43 }
44 }
45 }
46
47 read();
48
49 return () => {
50 controller.abort();
51 };
52 }, [url, token]);
53
54 return events;
55}
โ„น

info

Share one connection across components. Use a module-level singleton or a React context. If every component opens its own connection, you will exhaust browser limits and server capacity quickly.
Server-Side Patterns

On the server, real-time usually means pub/sub and presence. A publish/subscribe layer decouples the event producers from the consumers. A presence layer tracks who is currently online. These are usually implemented with Redis, a managed real-time service, or a combination of both.

Next.js itself does not store connection state. The real-time server or service does. If you build a custom WebSocket server, you will likely use Redis Pub/Sub to broadcast messages across multiple server instances. When a user sends a message, it is published to a Redis channel; every WebSocket server subscribed to that channel forwards it to its connected clients.

socket-server.ts
TSX
1// server/socket-server.ts
2import { createServer } from "http";
3import { Server } from "socket.io";
4import { createClient } from "redis";
5
6const httpServer = createServer();
7const io = new Server(httpServer, { cors: { origin: process.env.CLIENT_URL } });
8const redis = createClient({ url: process.env.REDIS_URL });
9const subscriber = redis.duplicate();
10
11async function main() {
12 await redis.connect();
13 await subscriber.connect();
14
15 io.on("connection", (socket) => {
16 socket.on("join", (roomId: string) => {
17 socket.join(roomId);
18 });
19
20 socket.on("message", async ({ roomId, text }: { roomId: string; text: string }) => {
21 // Publish to Redis so every node forwards to its clients
22 await redis.publish(
23 `room:${roomId}`,
24 JSON.stringify({ text, ts: Date.now() })
25 );
26 });
27 });
28
29 // Subscribe to all room channels or use a pattern subscriber
30 await subscriber.subscribe("room:*", (message) => {
31 const { text, ts } = JSON.parse(message);
32 io.emit("message", { text, ts });
33 });
34
35 httpServer.listen(3001);
36}
37
38main();

Rate limiting is essential for real-time endpoints. A malicious or buggy client can spam events and overwhelm your server or pub/sub layer. Limit connections per IP, messages per connection, and burst rates per room. Apply the same validation rules to real-time payloads that you apply to HTTP request bodies.

Authentication for real-time connections should be checked at connection time and revalidated periodically. For Socket.io, use a middleware that verifies the token before allowing the connection. For SSE, validate the cookie or query token on the initial request and reject it if the session is invalid. Do not trust the client to identify itself in event payloads.

โš 

warning

Never trust client-supplied room IDs or user IDs in a real-time event. Always derive the user identity from the authenticated session, and validate that the user is authorized to publish to the target room.
Security Considerations

Real-time systems increase the attack surface. A persistent connection can be held open indefinitely, used as a DoS vector, or exploited to bypass HTTP security controls. The basic rules of web security still apply: validate inputs, authenticate users, and limit how much any single connection can do.

ConcernMitigation
Origin validationRestrict CORS to known domains; validate Origin header
Auth tokensVerify at connection time; use short-lived signed tokens
Rate limitingLimit connections per IP, messages per second, and room size
DoS protectionSet connection timeouts, payload size limits, and connection caps
Input validationSchema-validate every event payload; reject malformed data
Data leakageNever broadcast events to rooms the user is not authorized to join

For WebSockets, configure the connection with sensible limits. Set a maximum payload size, enable per-message compression only if you need it, and close idle connections after a timeout. On the server, use a library that handles framing safely and protects against malformed frames.

For SSE, the biggest risk is information disclosure through the URL. If you pass a token as a query parameter, it may be logged by proxies or server logs. Prefer cookie-based authentication and terminate the connection immediately if the session becomes invalid. Use HTTPS for all real-time traffic so tokens and payloads cannot be intercepted.

โœ•

danger

A real-time connection is not a secure channel just because it is persistent. Re-verify authorization on every sensitive action, and treat the client as untrusted even after it has connected.
Best Practices

1. Choose the simplest real-time primitive that meets your latency budget. SSE is often enough for dashboards and notifications; WebSockets are only needed for bidirectional, low-latency collaboration.

2. Keep Next.js stateless. Run persistent connections in a dedicated real-time service or external platform, not inside route handlers or Server Actions.

3. Share one connection across components. Use a module singleton or React context to avoid connection proliferation and inconsistent state.

4. Handle reconnection and buffering. Clients go offline; networks drop. Show disconnected states, buffer critical messages, and resume gracefully.

5. Clean up connections on unmount. Open connections leak memory and server capacity. Always close EventSource, WebSocket, or fetch stream readers in the effect cleanup function.

6. Use Suspense and streaming RSC for progressive page loads. Wrap slow data dependencies so the shell renders immediately and the rest streams in.

7. Evaluate PPR for static shells with dynamic holes. It can replace a lot of client-side fetching with a fast, cached static page and a small dynamic island.

8. Provide fallbacks for no-JS and offline states. Core content should still be accessible if the real-time layer fails. Do not make real-time the only way to read important data.

9. Rate-limit and validate every real-time event. Treat event payloads with the same suspicion as HTTP request bodies.

10. Monitor connection health. Log connection rates, reconnection storms, error rates, and message latency. Real-time systems fail silently if you are not watching.

Common Pitfalls

1. Trying to run a WebSocket server in a route handler. Route handlers are stateless; the connection will be dropped when the response ends or the function recycles.

2. Creating a new connection per component. This exhausts browser connection limits and overloads the server. Share connections via a singleton or context.

3. Forgetting to unsubscribe on unmount. Off-by-one event listeners accumulate and cause stale updates or memory leaks.

4. Trusting client-sent room or user IDs. Always derive identity from the authenticated session, never from the event payload.

5. Using WebSockets for one-way push. SSE is simpler, works over HTTP, and reconnects automatically. Use WebSockets only when you need bidirectional messaging.

6. Blocking the entire page with a slow query. Use Suspense boundaries so one slow data dependency does not delay the whole stream.

7. Exposing long-lived tokens in the URL. For SSE, prefer cookies or short-lived signed query parameters that expire quickly.

8. Ignoring connection limits. Browsers cap concurrent connections per origin; plan for it, especially with tabs or multi-room clients.

9. Treating real-time as a substitute for durable state. Messages may be lost during reconnection. Persist critical data to a database before broadcasting it.

10. Shipping PPR without testing the static/dynamic split. Misplaced dynamic APIs can defeat the entire prerender, leaving you with a fully dynamic page.

๐Ÿ”ฅ

pro tip

When you are unsure, start with polling or SSE, measure the latency, and move to WebSockets only when the user experience demands it. A simpler architecture that works is better than a complex real-time system that is hard to operate.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-RTยทRevision: 1.0