You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
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.
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:
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.tsimporttype{Config}from"@react-router/dev/config";exportdefault{prerender: ["/","/about","/blog",/* ...dozens of routes... */],}satisfiesConfig;
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:
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.
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.
Summary
With
prerenderenabled 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'slisten()and get refused. The request helper has a retry loop, but it defaults toretryCount = 0and the publicprerenderconfig gives you no way to change that, so one transientECONNREFUSEDfails 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.0vite: 8.0.16react-router.config.tsThe line numbers below index the minified
dist/vite.jsbundle in@react-router/dev@8.1.0. The equivalent source lives inpackages/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 buildoccasionally throws during prerendering: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
/*.dataresource 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.
Prerendering starts an ephemeral-port preview server and fetches each route over
node:http. The preview server starts withport: 0(vite.js:954), so the OS picks a random port, and the base URL is read back frompreviewServer.resolvedUrls?.local[0](vite.js:963). Each request goes throughnodeHttpFetch, which callshttp.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 tonode:httpcame in feat!: require Vite environment API #15077 (8.0.0) to fix a Windows libuv assertion on teardown.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'sconfig()(vite.js:797):retryCount = 0, retryDelay = 500, timeout = 1e4. A transport error such as anECONNREFUSEDfromnode:httpis retried only while++attemptCount <= retryCount(vite.js:831). WithretryCount = 0that condition is1 <= 0, which is false, so there is no retry and control falls through tohandleError. The request itself is sent withAbortSignal.timeout(timeout)(vite.js:816), anddefaultHandleError(vite.js:884) rethrowsPrerender: Request failed for <path>: <message>(vite.js:887), which aborts the build.The public
prerenderconfig exposes onlypathsandconcurrency, so none of those retry values can be changed. They could in principle be overridden through the plugin'sconfig()callback, but React Router never threads user config into them. Both call sites of theprerender()plugin (vite.js:2088for the standard SSR build andvite.js:3507for thessr: falseSPA-fallback build) return aconfig()that forwards onlybuildDirectoryandconcurrency.getPrerenderConcurrencyConfig(vite.js:2656) reads onlyconcurrencyoff the user'sprerenderobject, and the public type ispathsplus optionalconcurrency(inconfig-DvmRcD4Z.d.ts). So there is no way to opt into the retry loop, lengthen the 10s timeout, or pin the port from config.The cold-start
ECONNREFUSEDhappens even thoughvite.preview()is awaited.startPreviewServerawaitsvite.preview()(vite.js:950), andresolvedUrlsis only set after the server emitslistening, so the socket should accept connections before the first fetch. Two things still leave a window:vite.js:954) there is a gap between the listening socket existing (andresolvedUrlsbeing populated) and the kernel'slisten()backlog actually accepting on that port for the advertised loopback address. Because every request usesconnection: closeandagent: false(vite.js:918), the first request does a coldconnect()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 andnode:httpsurfacesECONNREFUSED.retryCount = 0there is no tolerance for it: the first cold-connect failure is fatal. A single retry afterretryDelaywould 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
ECONNREFUSEDis present but inert by default (retryCount = 0,vite.js:797) and unreachable from public config (concurrency-only passthrough atvite.js:2088,vite.js:3507, andvite.js:2656). Together with the hardcoded ephemeralport: 0server (vite.js:954) and the per-requestconnection: closesocket (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:
Use a framework-mode app with a sizeable
prerenderlist inreact-router.config.ts, say a few dozen paths, so the first request fires right aftervite.preview()resolves and many connections open in quick succession:Run
react-router buildover 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'slisteningevent and the firstnode:httpconnect:Now and then the first
/_.datarequest (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, merged, shipped 8.0.0) made the preview-server prerender path the default and switched internal prerender requests from undicifetchtonode:http, to fix a Windows libuv assertion on teardown. That is the same code path that now throwsECONNREFUSED. It fixed an OS-specific teardown crash, not a connection-refused or readiness race.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) is the original implementation.Run buildEnd after preview prerendering, in 8.1.0) touch the same prerender lifecycle but only reorder thebuildEndhook. They do not changestartPreviewServer(port: 0), theretryCount = 0default, the retry guard, or the publicprerenderconfig 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 thisECONNREFUSED/ 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:httpswitch (#15077) shipped in 8.0.0 and is the code referenced above. #15211 (8.1.0) only reorders thebuildEndhook relative to prerendering; it does not touch the ephemeralport: 0preview server, theretryCount = 0default, the retry guard, or the publicprerenderconfig type. Upgrading to 8.1.0 leaves the race in place.