Skip to content

[Bug]: v8 preview-server prerendering intermittently fails with Prerender: Request failed for /_.data: connect ECONNREFUSED 127.0.0.1:<port> (cold-start race against the preview server) #15255

Description

@mjablons

Summary

With prerender enabled in framework mode, the build sometimes fails during the prerender pass with:

Error: Prerender: Request failed for /_.data: connect ECONNREFUSED 127.0.0.1:<port>

Since #15077 made Vite-preview-server prerendering the default, each route is prerendered by fetching it over a fresh TCP connection from a preview server started on an ephemeral port (port: 0). The first request can race the server's listen() and get refused. The request helper has a retry loop, but it defaults to retryCount = 0 and the public prerender config gives you no way to change that, so one transient ECONNREFUSED fails the whole build. It passes on a laptop and fails now and then in constrained CI containers.

Version

  • react-router: 8.1.0
  • @react-router/dev: 8.1.0
  • vite: 8.0.16
  • Node: 22.22
  • Framework mode, prerender configured in react-router.config.ts

The line numbers below index the minified dist/vite.js bundle in @react-router/dev@8.1.0. The equivalent source lives in packages/react-router-dev/vite/. The relevant code is the same in 8.0.1 and 8.1.0 (see the note at the end).

What happens

react-router build occasionally throws during prerendering:

Error: Prerender: Request failed for /_.data: connect ECONNREFUSED 127.0.0.1:<port>
    at defaultHandleError (.../@react-router/dev/dist/vite.js)

It is intermittent. The same inputs pass on most runs and fail on a few. I cannot reproduce it on a developer laptop, but it shows up occasionally in CI inside a Docker container on a constrained runner. The request that fails is usually one of the /*.data resource requests (for example /_.data), though the HTML request for the same route can fail the same way. Every prerendered route makes at least two requests (HTML and .data), so there are roughly twice as many chances to hit the race as there are paths.

Root cause

A cold-start race against the preview server, made fatal because retries are off by default and there is no way to turn them on from config.

  1. Prerendering starts an ephemeral-port preview server and fetches each route over node:http. The preview server starts with port: 0 (vite.js:954), so the OS picks a random port, and the base URL is read back from previewServer.resolvedUrls?.local[0] (vite.js:963). Each request goes through nodeHttpFetch, which calls http.request({ ..., agent: false, headers: { connection: "close" } }) (vite.js:918), i.e. a new TCP connection per request with no keep-alive. The switch from undici to node:http came in feat!: require Vite environment API #15077 (8.0.0) to fix a Windows libuv assertion on teardown.

  2. The retry loop exists but defaults to retryCount = 0, so the first connection error is fatal. The retry, delay, and timeout values are destructuring defaults read only from the plugin's config() (vite.js:797): retryCount = 0, retryDelay = 500, timeout = 1e4. A transport error such as an ECONNREFUSED from node:http is retried only while ++attemptCount <= retryCount (vite.js:831). With retryCount = 0 that condition is 1 <= 0, which is false, so there is no retry and control falls through to handleError. The request itself is sent with AbortSignal.timeout(timeout) (vite.js:816), and defaultHandleError (vite.js:884) rethrows Prerender: Request failed for <path>: <message> (vite.js:887), which aborts the build.

  3. The public prerender config exposes only paths and concurrency, so none of those retry values can be changed. They could in principle be overridden through the plugin's config() callback, but React Router never threads user config into them. Both call sites of the prerender() plugin (vite.js:2088 for the standard SSR build and vite.js:3507 for the ssr: false SPA-fallback build) return a config() that forwards only buildDirectory and concurrency. getPrerenderConcurrencyConfig (vite.js:2656) reads only concurrency off the user's prerender object, and the public type is paths plus optional concurrency (in config-DvmRcD4Z.d.ts). So there is no way to opt into the retry loop, lengthen the 10s timeout, or pin the port from config.

  4. The cold-start ECONNREFUSED happens even though vite.preview() is awaited. startPreviewServer awaits vite.preview() (vite.js:950), and resolvedUrls is only set after the server emits listening, so the socket should accept connections before the first fetch. Two things still leave a window:

    • With an ephemeral port (vite.js:954) there is a gap between the listening socket existing (and resolvedUrls being populated) and the kernel's listen() backlog actually accepting on that port for the advertised loopback address. Because every request uses connection: close and agent: false (vite.js:918), the first request does a cold connect() with no warmed socket. If it races the backlog becoming ready, or resolves to a different loopback address than the one bound, the SYN gets an RST and node:http surfaces ECONNREFUSED.
    • On a slow or oversubscribed CI runner the gap is wider, and with retryCount = 0 there is no tolerance for it: the first cold-connect failure is fatal. A single retry after retryDelay would almost always succeed against the now-ready socket, but that retry is not reachable from config.

So the retry loop that would make prerendering tolerant of a transient cold-start ECONNREFUSED is present but inert by default (retryCount = 0, vite.js:797) and unreachable from public config (concurrency-only passthrough at vite.js:2088, vite.js:3507, and vite.js:2656). Together with the hardcoded ephemeral port: 0 server (vite.js:954) and the per-request connection: close socket (vite.js:918), one refused connection fails the whole prerender pass.

Reproduction

A race is hard to make deterministic, but this widens the window enough to hit it:

  1. Use a framework-mode app with a sizeable prerender list in react-router.config.ts, say a few dozen paths, so the first request fires right after vite.preview() resolves and many connections open in quick succession:

    // react-router.config.ts
    import type { Config } from "@react-router/dev/config";
    export default {
      prerender: ["/", "/about", "/blog", /* ...dozens of routes... */],
    } satisfies Config;
  2. Run react-router build over and over inside a constrained container (for example a CPU-limited Docker container on a CI runner), which widens the gap between the preview server's listening event and the first node:http connect:

    for i in $(seq 1 50); do react-router build || break; done
  3. Now and then the first /_.data request (or a route's HTML request) is refused and the build aborts with the error above. The same build passes on an unconstrained laptop, which is why it reads as a flaky CI failure.

The frequency goes up the more constrained the container is, and down the warmer the loopback stack is. It is not meant to fail every run; it is a race.

Related

  • feat!: require Vite environment API #15077 (feat!: require Vite environment API, merged, shipped 8.0.0) made the preview-server prerender path the default and switched internal prerender requests from undici fetch to node:http, to fix a Windows libuv assertion on teardown. That is the same code path that now throws ECONNREFUSED. It fixed an OS-specific teardown crash, not a connection-refused or readiness race.
  • RFC discussion RFC: Prerendering with Vite Preview Server #14651 (RFC: Prerendering with Vite Preview Server) defines the "start a preview server, make HTTP requests to it" design. Its trade-offs section mentions "performance impact due to preview server startup time" but has no readiness handshake, retry, or connection-refused handling.
  • feat: prerendering with vite preview server #14650 (feat: prerendering with vite preview server) is the original implementation.
  • Run buildEnd after prerendering #15211 / [v7] Run buildEnd after preview prerendering #15212 (Run buildEnd after preview prerendering, in 8.1.0) touch the same prerender lifecycle but only reorder the buildEnd hook. They do not change startPreviewServer (port: 0), the retryCount = 0 default, the retry guard, or the public prerender config type, so the race is still there in 8.1.0.

I searched the issue tracker and discussions for the error and its variants (prerender ECONNREFUSED, Prerender: Request failed, preview server prerender, retryCount, and so on) and did not find an existing report of this ECONNREFUSED / readiness race, or any maintainer comment on it. The two open prerender issues look unrelated: #14587 (prerender: true, ssr: false does not work with basename) and #15025 (Prerendered routes ship Suspense fallback in shell). So I am filing this as a new issue rather than commenting on either.

Note on 8.1.0

The node:http switch (#15077) shipped in 8.0.0 and is the code referenced above. #15211 (8.1.0) only reorders the buildEnd hook relative to prerendering; it does not touch the ephemeral port: 0 preview server, the retryCount = 0 default, the retry guard, or the public prerender config type. Upgrading to 8.1.0 leaves the race in place.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions