Skip to content

[miniflare] Loopback server uses Node's default 5s keepAliveTimeout, causing intermittent "Network connection lost" in workerd #14848

Description

@exKAZUu

Summary

Miniflare's loopback server (#startLoopbackServer in packages/miniflare/src/index.ts) is a plain node:http server with default timeouts, so it closes idle keep-alive sockets after Node's default server.keepAliveTimeout of 5 seconds. workerd pools and reuses connections to the loopback server, so there is a classic keep-alive race: Node closes an idle pooled socket (clean FIN, no error on the Node side) at the same moment workerd sends the next request on it, and that request fails inside workerd with Error: Network connection lost..

Any binding served over the loopback (custom serviceBindings functions, __VITE_INVOKE_MODULE__ in @cloudflare/vite-plugin, etc.) is affected. The failure is probabilistic and load-dependent: the more loopback requests a session makes and the more multi-second idle gaps there are between bursts, the more likely it hits.

Notably, Miniflare already disables timeouts in the opposite direction — the undici Pools used for Node → workerd dispatch are created with headersTimeout: 0, bodyTimeout: 0 and a comment saying "Disable timeouts for local dev". The loopback server (workerd → Node) is the one direction that was left on defaults.

Where it bites hard: @cloudflare/vite-plugin with a large module graph

In a vite dev session using @cloudflare/vite-plugin (RSC/SSR child environments, a large dependency excluded from optimizeDeps), a cold optimizer cache produces ~3,000 fetchModule invokes through __VITE_INVOKE_MODULE__ per page load, with multi-second idle gaps while Vite transforms/optimizes. In my project this made vite dev answer 500 on every SSR page in roughly 2 out of 3 cold starts, with:

Error: Network connection lost.
    at runInRunnerObject (workers/runner-worker/index.js:107:3)
    at Object.load (@vitejs/plugin-rsc/dist/ssr.js:28:29)

I suspect at least part of #11142 (icon library + deps_ssr graph → runtime "Network Error", closed as unreproducible) is this same mechanism. It is distinct from the workerd OOM in #14701 — in my repro workerd never crashes; the DO and isolate stay alive and only the loopback fetch fails.

Evidence

I instrumented @cloudflare/vite-plugin's runner-worker transport.invoke and Miniflare's loopback server:

  • Exactly 1–2 out of ~3,000 env.__VITE_INVOKE_MODULE__.fetch() calls reject with Network connection lost. (e.g. count=3079 inflight=8). The Durable Object keeps serving RPCs afterwards, so the actor was not aborted.
  • On the failing runs, the Node side logs zero socket errors — consistent with Node closing an idle keep-alive socket cleanly and workerd losing the race on reuse.
  • A/B test with a cold Vite optimizer cache (rm -rf node_modules/.vite), same app, same machine:
Miniflare loopback server Cold-start result
stock (default keepAliveTimeout = 5 s) 2/3 runs fail (500, 1–2 invoke rejections each)
patched with server.keepAliveTimeout = 0; server.headersTimeout = 0; 4/4 runs succeed, 0 invoke rejections

Reproduced with the Vite process running under both Node 24.18.0 and Bun (same 5 s default in both node:http implementations). Versions: miniflare 4.20260701.0, @cloudflare/vite-plugin 1.43.0, vite 8.1.3 (also reproduces with @cloudflare/vite-plugin 1.45.1); the loopback server code on main is unchanged.

Proposed fix

Disable the idle timeouts on the loopback server, mirroring what Miniflare already does for its undici pools:

#startLoopbackServer(hostname: string): Promise<StoppableServer> {
	if (hostname === "*") hostname = "::";

	return new Promise((resolve) => {
		const server = stoppable(
			http.createServer(this.#handleLoopback),
			/* grace */ 0
		);
		// Disable timeouts for local dev — workerd pools and reuses connections
		// to the loopback server, and Node's default keepAliveTimeout (5s)
		// races with that reuse, surfacing as "Network connection lost".
		server.keepAliveTimeout = 0;
		server.headersTimeout = 0;
		server.on("upgrade", this.#handleLoopbackUpgrade);
		server.listen(0, hostname, () => resolve(server));
	});
}

I'll open a PR with this change.

Reproduction

Hard to make minimal because it needs a large module graph and real transform latency, but reliable in a real app:

  1. Vite app with @cloudflare/vite-plugin (viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] } via a RSC framework), a big UI library in optimizeDeps.exclude.
  2. rm -rf node_modules/.vite && vite dev
  3. curl http://localhost:<port>/<ssr-page> → 500 in ~2/3 of cold starts.

A synthetic repro should also work: a Worker that makes a few thousand fetches to a custom service binding (function), with occasional >5 s pauses between bursts so pooled sockets go idle.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Untriaged

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions