Why WebSockets Hang in Next.js API Routes and How a Custom Server Fixes It
Learn why a ws server inside a pages/api route never completes the handshake, and how to handle the upgrade event yourself with a custom Next.js server.
This article is published in English.
Sources: The Next.js WebSocket Trap: Why Your pages/api Handler Never Upgrades
A common first attempt at WebSockets in Next.js is to create a ws server inside pages/api/socket.ts and connect to it from the browser. The result is confusing: no error, no message, just a socket sitting in the CONNECTING state until the browser times out. This guide explains why that happens, shows how to take over the HTTP server so the handshake actually completes, and covers the scaling and hosting limits you accept in exchange.
Why an API route can never upgrade the connection
A Next.js API route is an ordinary HTTP request handler. It runs once a request has already been routed as plain HTTP. A WebSocket handshake, however, is an HTTP request carrying an Upgrade header, and it must be caught on the Node server's upgrade event, before any router processes it. By the time your route function executes, that opportunity has passed. The route replies with JSON while the client is waiting for a 101 Switching Protocols response, and neither side makes progress.
The broken version starts with the usual imports, including Next's API types:
// pages/api/socket.ts — DOES NOT WORK
import { WebSocketServer } from "ws";
import type { NextApiRequest, NextApiResponse } from "next";
The route then creates a WebSocketServer and attaches a connection handler that echoes messages, but its exported handler simply returns a JSON status.
const wss = new WebSocketServer({ noServer: true });wss.on("connection", (ws) => {
ws.on("message", (msg) => ws.send(`echo: ${msg}`));
});export default function handler(_req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ status: "ws server" });
}
The wss instance exists, and noServer: true is even the right setting. What is missing is any call to handleUpgrade. Nothing hands the socket to ws, so the browser's new WebSocket(...) never finishes the handshake.
Taking ownership of the HTTP server
The solution is to stop expecting an API route to do this and control the HTTP server directly. Next.js supports a custom server: you create the Node HTTP server yourself, pass normal requests to Next's request handler, and listen for upgrade events on your own.
// server.ts
import { createServer } from "node:http";
import next from "next";
import { WebSocketServer, type WebSocket } from "ws";
import { parse } from "node:url";
The server script below does four things:
- It boots Next in dev or production mode and obtains the default request handler.
- It creates a
WebSocketServerwithnoServer: trueand tracks open sockets in aSet, removing them oncloseanderror. - On each incoming message it loops over every open client and forwards the text, which turns a per-socket echo into a simple broadcast room.
- After
app.prepare()resolves, it creates the HTTP server, and in theupgradelistener checks the pathname:/api/wsis passed towss.handleUpgrade, anything else is destroyed. Finally it listens onPORTor 3000.
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();const wss = new WebSocketServer({ noServer: true });
const clients = new Set<WebSocket>();wss.on("connection", (ws) => {
clients.add(ws);
ws.on("message", (data) => {
const text = data.toString();
for (const client of clients) {
if (client.readyState === client.OPEN) client.send(text);
}
});
ws.on("close", () => clients.delete(ws));
ws.on("error", () => clients.delete(ws));
});app.prepare().then(() => {
const server = createServer((req, res) => handle(req, res, parse(req.url!, true))); server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url ?? "/", "http://localhost");
if (pathname === "/api/ws") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} else {
socket.destroy();
}
}); const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`ready on http://localhost:${port}`));
});
The two details that carry the design
First, noServer: true tells ws not to open a listener of its own, because you will feed it upgrade requests manually.
Second, the pathname check matters more than it appears. Without it, every upgrade request reaching the server would be pushed into your handleUpgrade, including the hot module replacement socket that Next uses in development. The guard keeps your /api/ws endpoint separate. Be aware, though, that the else branch destroys all other upgrades; depending on your Next.js version, that can cut off the dev HMR connection too. If hot reload stops working, forward non-matching upgrades to Next instead of destroying them (check the custom server section of the current Next.js docs for the supported way to do this).
The argument order of handleUpgrade is easy to get wrong. As documented for ws 8.18.3, it is handleUpgrade(request, socket, head, callback). Inside the callback you emit connection on the server yourself, so the wss.on("connection") handler runs exactly as it would for a standalone server.
The client component
The browser side is deliberately plain. As a client component, it begins with the "use client" directive and the React hooks it needs.
"use client";
import { useEffect, useState } from "react";
LiveFeed opens a socket to the same path the server guards, appends each incoming message to state, and renders the list.
export function LiveFeed() {
const [messages, setMessages] = useState<string[]>([]); useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${window.location.host}/api/ws`); ws.onmessage = (event) => {
setMessages((prev) => [...prev, event.data as string]);
}; return () => ws.close();
}, []); return (
<ul>
{messages.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
);
}
Two choices here prevent real bugs:
- The protocol is derived from
window.location.protocolinstead of hardcodingws://. In production the page is served over HTTPS, and browsers block an unencryptedws://connection from anhttps://page, so hardcoding would fail only after deployment. - The effect returns a cleanup that calls
ws.close(). In development, React Strict Mode runs effects twice, so without the cleanup you would leak one extra socket on every mount.
Running the server
Start the app with tsx server.ts, or your compiled equivalent in production, rather than next dev. Giving up the zero-configuration dev command in exchange for owning the HTTP server is the real cost of native WebSockets in this setup.
Verifying the behavior
A quick manual check, run on Node 24 with ws 8.18.3, confirms each piece:
- With two clients connected, a message such as
hello from asent by the first arrives at the second through the broadcast loop. - A socket opened against any path other than
/api/wsfails immediately rather than hanging, becausesocket.destroy()closes it at once instead of leaving it in limbo like the API-route version. - An ordinary HTTP route on the same server still returns its body, proving that Next's request handling and the WebSocket upgrade share one listener.
Where this approach stops scaling
An in-memory Set of clients in a single process is fine until you run more than one instance. With two pods behind a load balancer, a message received on pod A never reaches a client connected to pod B, because each process has its own clients set. At that point you need a pub/sub layer, most often Redis, to fan messages out across instances, or a managed real-time service. For a deeper look at rooms, persistence and fan-out, see designing real-time chat backends. Do not build that on day one: one Node process holds many concurrent sockets, and many apps never outgrow it.
Hosting constraints
A custom server also removes you from part of Next's serverless deployment model. WebSockets need a long-lived process, not a function invocation such as a Lambda. If your host only runs serverless functions, WebSockets will not work there regardless of how you write the handler, so confirm this before investing in the feature.
Key takeaways
- The WebSocket handshake happens on the server's
upgradeevent, which an API route never sees. - A custom server lets you route upgrades to
wswithnoServer: trueandhandleUpgrade, while Next handles normal HTTP. - Guard upgrades by pathname, and be deliberate about what happens to upgrades that are not yours.
- Derive
wsorwssfrom the page protocol and always close sockets in effect cleanup. - In-memory broadcasting is single-instance only, and serverless hosting rules out this pattern entirely.