localhost:5173 / proxy-websocket
Proxying WebSocket connections through the Vite dev server
A WebSocket through the dev server needs ws: true (or a target starting with ws:// or wss://) on its proxy entry, because upgrade requests are handled by a separate listener that ignores every rule without one.
WebSocket connection to 'ws://localhost:5173/socket.io/?EIO=4' failedThe entry that forwards a socket
One flag separates a proxy rule that forwards a socket from one that only forwards the HTTP request that opens it. Without ws, the upgrade never reaches the target and the browser reports a failed connection with no server-side error to match it.
server: {
proxy: {
'/socket.io': {
target: 'ws://localhost:5174',
ws: true,
},
},
}An upgrade does not go through the proxy middleware
Normal requests pass through viteProxyMiddleware in the connect chain. Upgrades never get there — Vite registers a second listener on the HTTP server and matches the rules again, in configuration order. This is why a proxy that demonstrably works for /api can do nothing at all for a socket on the same prefix: two code paths, one config.
httpServer.on('upgrade', async (req, socket, head) => {
const url = req.url!
for (const context in proxies) {
const { proxy, options: opts, match } = proxies[context]
if (match(url)) {
if (
opts.ws ||
opts.target?.toString().startsWith('ws:') ||
opts.target?.toString().startsWith('wss:')
) { /* ... proxy.ws(req, socket, head) */ }
}
}
})ws: true is not the only trigger
The same condition accepts a target whose protocol is ws: or wss:. An entry written for a socket server therefore forwards upgrades even without the flag — and the reverse trap: a rule you added ws: true to, but pointed at an http: target, still proxies the upgrade and lets http-proxy handle the protocol switch. Pick one and be consistent; the documented example sets both.
# these two behave the same for an upgrade
'/socket': { target: 'ws://localhost:8080' },
'/socket': { target: 'http://localhost:8080', ws: true },A matching rule without ws does not block the socket
The loop checks the ws condition inside the match, and only a forwarded socket returns. A rule that matches the upgrade URL but has neither ws nor a ws: target is skipped, and the next rule gets its turn. So the fix for a socket under an existing HTTP prefix is a second, more specific entry above it — not a rewrite of the first one.
proxy: {
// upgrades fall through this one
'/api': { target: 'http://localhost:8080', changeOrigin: true },
// ...and are picked up here
'/api/live': { target: 'ws://localhost:8080', ws: true },
}Why a catch-all rule kills hot reload
The HMR socket is an upgrade on the same server. Vite identifies it by two things at once: the sub-protocol is vite-hmr or vite-ping, and the path equals the base (plus server.ws.path when set). But the proxy listener runs on the same upgrade and only looks at the URL. A key of "/" with ws: true matches that path as well and hands the HMR socket to your backend, which does not know what to do with it. Keep the proxy key specific, or move the HMR socket with server.ws.path.
hmrServerWsListener = (req, socket, head) => {
const protocol = req.headers['sec-websocket-protocol']!
const parsedUrl = new URL(`http://example.com${req.url!}`)
if (
[HMR_HEADER, 'vite-ping'].includes(protocol) &&
parsedUrl.pathname === hmrBase
) {
handleUpgrade(req, socket as Socket, head, protocol === 'vite-ping')
}
}rewriteWsOrigin, and what it turns off
Many socket servers reject an Origin they do not know, and the browser sends localhost:5173. rewriteWsOrigin replaces it with the target origin so the handshake passes. The documentation is blunt about the price: Vite does not check the origin of WebSocket requests before proxying, and rewriting it bypasses the check on the other side too. On a dev server reachable from the network, that is an open relay into your backend socket.
'/socket.io': {
target: 'ws://localhost:5174',
ws: true,
// the target's own Origin check no longer protects you
rewriteWsOrigin: true,
}bypass behaves differently on an upgrade
The second argument of bypass is the response object — and for an upgrade request there is none, so it arrives as undefined. Code that reads res.setHeader throws inside the upgrade listener. Worse, a string return value only assigns req.url and returns: for an HTTP request that means Vite serves the file, for an upgrade it means nobody answers and the socket hangs until the client times out. Return false for a rejection, undefined to proxy.
bypass?: (
req: http.IncomingMessage,
/** undefined for WebSocket upgrade requests */
res: http.ServerResponse | undefined,
options: ProxyOptions,
) => void | null | undefined | false | string | Promise<...>Seeing which rule took the socket
The debug namespace prints a separate line for upgrades, with the target after the arrow. If no line appears, no rule matched the upgrade or the matching one had no ws. If the line appears for a URL you did not expect, a broader key above your socket entry took it first.
$ DEBUG=vite:proxy npm run dev # an upgrade that was forwarded vite:proxy /socket.io/?EIO=4 -> ws ws://localhost:5174 # a normal request, for comparison — no "ws" before the target vite:proxy /api/users -> http://localhost:8080
The fallback message that is not your proxy
When the HMR socket cannot connect through whatever sits in front of Vite, the client tries again directly against the dev server and logs a line about it. It is informational, and it only happens while no HMR port is configured — the client checks for that before falling back. Setting server.ws.clientPort or server.ws.port removes the second attempt, and with it the console error people usually blame on the proxy.
[vite] Direct websocket connection fallback. Check out https://vite.dev/config/server-options.html#server-ws to remove the previous connection error. # client.ts: the fallback is guarded by if (!hmrPort)
# faq
Questions
Does the Vite proxy forward WebSockets by default?
No. An upgrade is only forwarded when the matching entry sets ws: true or its target starts with ws:// or wss://.
Why does my proxy work for HTTP but not for the socket on the same path?
Upgrades are handled by a separate listener on the HTTP server, not by the proxy middleware. That listener skips every rule without ws.
Can a proxy rule break HMR?
Yes. A key broad enough to match the HMR path — "/" being the common case — with ws: true forwards the HMR socket to your backend.
Is rewriteWsOrigin safe to leave on?
It rewrites the Origin header to the target, which defeats the origin check on the target. Vite does not check the origin itself, so the documentation advises caution.
What is the "Direct websocket connection fallback" message?
The HMR client could not reach the WebSocket through the proxy in front of Vite and connected directly instead. It only occurs when no HMR port is configured.
# next
Related
# sources
- Vite — server.proxy
- Vite — server.ws
- Vite source — proxy middleware (v8.3.0)
- Vite source — WebSocket server (v8.3.0)
- Vite source — HMR client (v8.3.0)
Checked against the Vite documentation on 2026-09-16.