Table of contents
At WebSandbox, our mission is to provide a seamless, zero-config cloud development environment. But recently, we ran into a bizarre networking issue: users spinning up modern frameworks like Vite or Astro were being hit with a cryptic ECONNREFUSED 0.0.0.0 error when trying to preview their apps. Here is the story of how a legacy proxy configuration collided with modern Node.js networking, and how we solved it platform-wide.
The Problem: The "ECONNREFUSED" Mystery
Our infrastructure relies heavily on advanced proxying to route traffic from sandboxed environments to your browser. Under the hood, we leverage components similar to code-server to intercept and forward preview requests.
Everything worked perfectly for established frameworks like Next.js or Express. But when users booted up Vite, SvelteKit, or Astro, the terminal would show the server running smoothly on http://localhost:4321, while the preview pane threw a fatal error:
connect ECONNREFUSED 0.0.0.0:4321
The standard community workaround for this is to tell users to manually append the --host 0.0.0.0 flag to their dev scripts. But at WebSandbox, we believe in "it just works." Forcing every user to modify their package.json was an unacceptable UX compromise. We needed to find the root cause.
The Investigation: Peeking Under the Hood
To understand why this was happening, we audited the proxy routing code that handles the preview ports. We found that the internal proxy target was historically hardcoded to an IPv4 address:
proxy.web(req, res, { ignorePath: true, target: `http://0.0.0.0:${port}${req.originalUrl}`, })
The proxy was attempting to open a TCP connection to 0.0.0.0. But the operating system was violently rejecting the connection, insisting nothing was listening there. This led us to a fundamental question: if Vite says it's running on localhost, why does 0.0.0.0 fail?
The Root Cause: IPv6 and Node.js 17
For decades, developers operated under the assumption that localhost meant 127.0.0.1 (IPv4). Binding a server to localhost meant you could reach it via 0.0.0.0.
But as the internet transitioned to IPv6, a new loopback address was introduced: ::1. In Node.js 17, the core team made a monumental architectural change: dns.lookup() was updated to prefer IPv6 (::1) over IPv4 (127.0.0.1).
When modern dev servers like Astro or Vite call server.listen() on "localhost", Node.js 17+ binds them exclusively to the IPv6 loopback (::1).Because 0.0.0.0 is strictly an IPv4 address, our proxy was blindly searching for an IPv4 socket. Since Astro was exclusively bound to an IPv6 socket, the OS immediately dropped the connection. (This also explained why older Next.js apps worked—they often bind directly to 0.0.0.0 by default, accidentally bypassing the issue!)
The Fallacy of 127.0.0.1
Our first instinct was to simply change the proxy target to 127.0.0.1.
// A logical, but flawed fix proxy.web(req, res, { ignorePath: true, target: `http://127.0.0.1:${port}${req.originalUrl}`, })
We quickly realized this was a dead end. 127.0.0.1 is still an IPv4 address! If the user's dev server was bound to ::1, connecting to 127.0.0.1 would still result in the exact same ECONNREFUSED error.
The Solution: Happy Eyeballs
If we couldn't safely hardcode an IPv4 address, and we couldn't guarantee an IPv6 address, we needed the proxy to dynamically figure it out. The answer was to let DNS do its job by delegating the resolution back to the hostname.
// The WebSandbox implementation proxy.web(req, res, { ignorePath: true, target: `http://localhost:${port}${req.originalUrl}`, })
Historically, proxies avoided using localhost because older versions of Node.js didn't handle fallback well. But modern Node.js (v20+) fully implements an algorithm called "Happy Eyeballs" (RFC 8305) inside http.request.
By passing localhost to the proxy, Node.js now does the heavy lifting:
- Dual Resolution: It resolves
localhostto both::1(IPv6) and127.0.0.1(IPv4). - Concurrent Attempts: It attempts to connect to
::1first. - Seamless Fallback: If the connection fails (e.g., the dev server is bound to IPv4), Node.js invisibly and instantaneously falls back to
127.0.0.1.
Conclusion
By updating our internal proxy architecture to leverage Node.js's native Happy Eyeballs implementation, we completely eliminated the ECONNREFUSED issue. Now, whether our users boot up an ancient Express app on IPv4 or a cutting-edge Vite server on IPv6, WebSandbox routes their traffic perfectly—no --host flags required.
