Skip to content

fix: improve handling when a static asset fails to load - #3598

Merged
brillout merged 47 commits into
mainfrom
claude/intelligent-clarke-tgcdw7
Oct 15, 2026
Merged

brillout merged 47 commits into
mainfrom
claude/intelligent-clarke-tgcdw7

Conversation

@brillout

@brillout brillout commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Closes #3596

Builds on #3597: this branch includes its 12 commits, plus the commits listed below.

What changes

With Client Routing, the server-rendered page stays non-interactive when fetching the page's static assets fails during hydration. The failed import() throws the same error whether the asset is gone (a new frontend was deployed) or the connection failed. So Vike now checks the connection with a HEAD request to the page's URL:

  • The server responds → reload once. Most likely a new frontend was deployed, or else the connection is back. Either way, reloading fixes the page.

  • Network error, or no response within 4 seconds → let it fail. Vike behaves as on main:

    • it throws the error;
    • the server-rendered page stays;
    • Client Routing is disabled, so the next navigation is a full page load.

    Recovering from a flaky connection is left to the browser and the user. Reloading while the connection is down would replace the server-rendered page with the browser's offline page. The timeout prevents a reload long after the page was shown, e.g. while the user is filling a form.

  • Blank page (e.g. SPA) → reload once, without checking. A reload is better than a blank page, even if the connection is down: at worst the browser shows its offline page. Vike doesn't know whether the page was server-side rendered (ssr: false is a vike-react/vue/solid setting), so it checks whether the page shows any text (document.body.innerText).

  • Reload only once. Vike uses the Navigation Timing API to detect a page that was itself loaded by a reload, and doesn't reload it again. This covers e.g. a cached HTML page that keeps referencing the old assets. If such a page is blank, Vike renders its error page instead.

  • Production only. In development, such failures are usually transform errors, which a reload doesn't fix. They're now handled like any other error.

Upon client-side navigation, Vike still does a full page load of the target URL. The exception is an outdated navigation, i.e. the user navigated again in the meantime. Separately, module specifier resolution errors are no longer treated as failures to fetch static assets.

Vike logs a single message upon failing to fetch static assets: "Failed to fetch static asset (due to a connection issue, or a new deployment) — falling back to Server Routing".

Client-side cost: +268 B minified / +117 B gzipped compared to main. Measured on renderPageClient.ts and loadPageConfigsLazyClientSide.ts with esbuild minify + gzip.

Commits on top of #3597

  • test: the hydration retry tests always remove their routes and offline mode, even when an assertion fails
  • polish: reword the log upon failing to fetch static assets
  • feat: upon hydration, reload only if a new frontend was deployed
    • It replaces waiting for the server to be reachable before reloading: fix: reload page once if hydration fails to fetch static assets #3597's offline handling, and the earlier feat: … reload only once the server is reachable.
    • When it doesn't reload, it throws the error as on main. This replaces the earlier fix: keep the server-side rendered page …, which logged the error instead.
  • From the review:
    • minor: check only the page's entry
    • minor: reload unless the connection is down, instead of checking the response's status and content type
    • minor: say so when the connection is down
    • minor: rename isServerReachable() to isLikelyNewDeployment() and explain return values
    • minor: name the return values of handleErrorFetchingStaticAssets()
    • minor: rename isLikelyNewDeployment() to isNetworkError()
    • feat: upon hydration, reload a blank page (e.g. SPA) even upon a network error
    • minor: simplify the log upon failing to fetch static assets
  • From @brillout:
    • handleErrorFetchingStaticAssets() is split into three actions: show the error page, keep the broken server-rendered page, or hard reload. A reloaded blank page shows the error page. There's a single log message.
    • The connection check targets the page's URL instead of its entry, which removes the injectFilter() edge case.
    • Errors in development, and errors other than failures to fetch static assets, return early (handled like any other error).
    • The connection check times out after 4 seconds.
    • The tests and their pages are removed (4f4df97, 1758b98).
  • Follow-up fixes and review:
    • fix: render the error page if the page's code throws while it's loaded. showErrorPage() asserted that every error was a failure to fetch static assets.
    • test: update the hydration retry tests to the single log message and the page URL check
    • minor: give up the connection check after a timeout

Reverted:

  • feat: don't reload the page if the user entered data: without hydration the form can't be submitted anyway, so it isn't worth its client-side KBs.
  • fix: no infinite reload loop upon a persistent hydration error with an HTML-only error page: static asset failures no longer reach that code path. Also, fix: always use client router to render the error page #3600 has since removed the error page check that caused the loop.

Tests

None: 4f4df97 and 1758b98 removed the tests this PR had added, along with their pages. The PR now only changes renderPageClient.ts and loadPageConfigsLazyClientSide.ts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD

claude and others added 18 commits October 11, 2026 08:09
With Client Routing, if fetching the page's static assets failed during
hydration (e.g. flaky connection, or a new frontend was deployed while
the page was loading), the server-rendered page stayed non-interactive.

Vike now reloads the page in that case. To avoid an infinite reload
loop, a page that was itself loaded by a reload isn't reloaded again
(detected via the Navigation Timing API, so no storage is needed).
Production only: in development such failures are usually caused by
transform errors, which a reload doesn't fix.

Fixes #3596

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013M9XEWwcvkeKwfcC9LCwgc
Since the reloaded page now handles the error like any other error
(logs it and renders the error page) instead of rethrowing it, the
playground, which doesn't define an error page, also logs
"No error page defined." — which made the test fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013M9XEWwcvkeKwfcC9LCwgc
Reloading while offline replaces the (readable) server-side rendered
page with the browser's offline error page. Instead, wait for the
`online` event before reloading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013M9XEWwcvkeKwfcC9LCwgc
handleErrorFetchingStaticAssets() was called before the
isRenderOutdated() check, so a rendering that was already superseded
(e.g. the user navigated while the error page of the first render was
being loaded) could reload/redirect the page, overriding the user's
navigation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013M9XEWwcvkeKwfcC9LCwgc
…ch static assets

`Failed to resolve module specifier` (chromium) and `Error resolving
module specifier` (firefox) are thrown for unresolvable (e.g. bare)
import specifiers, before any network request is made. Reloading the
page (or redirecting upon client-side navigation) can't fix them: it
only added a pointless page load before the same error. They're now
handled like any other error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013M9XEWwcvkeKwfcC9LCwgc
The route blocking the page's entry is set on the browser context, which
is shared with the next tests of the test file, and so is the offline
mode. Remove them in a `finally` block so that a failing assertion
doesn't make the next tests fail too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
…n HTML-only error page

If an error occurs upon hydration and the error page isn't client-side
routable (e.g. an HTML-only error page), Vike used to redirect to the
current URL. But the server already rendered that URL: the server
renders the same page again, the error occurs again upon hydration, and
so on — an infinite reload loop if the error persists.

That's the case for any persistent hydration error, and also for a
persistent failure to fetch static assets: once the reloaded page gives
up, it handles the error like any other error. With an HTML-only error
page and an entry chunk that always fails to load, the page was loaded
172 times within 8 seconds.

Upon hydration, Vike now leaves the server-side rendered page as it is
instead, and falls back to Server Routing: the page isn't hydrated, so
Client Routing would wait forever for the hydration to end upon the next
link click. The error is still logged (by handleError()).

The new test/error-page-html-only/ covers both cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
…to fetch static assets

When the page was already reloaded and fetching its static assets fails
again, Vike gives up. It used to then handle the error like any other
error, i.e. render the error page, which replaces the server-side
rendered page. That's a downgrade: the server-side rendered page isn't
interactive but it shows the content the user requested (and the error
page itself may fail to load for the same reason).

Vike now logs the error and leaves the server-side rendered page as it
is. The same applies when the user reloaded the page manually.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
… once the server is reachable

Reloading while the server is unreachable replaces the server-side
rendered page (non-interactive, but readable) with the browser's network
error page. Checking `navigator.onLine` isn't enough: it's `true`
whenever the device is connected to a network, regardless of whether
the server is reachable (e.g. upon a brief loss of mobile connection).

Vike now first checks whether the server is reachable (a HEAD request to
the page's URL) and reloads only once it is. Otherwise it retries after
1s, 2s, 4s, ... (up to every 60s), and right away when the browser goes
back online.

Any HTTP response counts as reachable, except server errors (5xx). Vike
doesn't use status codes to tell apart a new frontend deployment from a
flaky connection: the old static assets aren't always `404` (e.g. S3
replies `403`, and static hosts with SPA fallback reply `200` with HTML)
and the failed dynamic import gives the same error in all cases — while
a reload fixes both anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
Form fields work without hydration: the user may have started filling a
form of the server-side rendered page while it was loading (or while
Vike was waiting for the server to be reachable again). Reloading the
page would discard what the user entered.

In that case, Vike now logs the error and leaves the server-side
rendered page as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
@brillout brillout changed the title Implement hydration retry mechanism for failed static assets fix: reload page if hydration fails to fetch static assets Oct 11, 2026
This reverts commit 70c519e.

Its value is limited: without hydration, the form's client-side submit
handler doesn't run, so the user can't submit what they typed anyway —
keeping the page only lets them copy it, while the page stays
non-interactive. Not worth its client-side KBs (~120 bytes gzipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
…r with an HTML-only error page"

This reverts commit f9cb8db.

Moved to a separate PR: since "keep the server-side rendered page when
the reloaded page fails to fetch static assets", failing to fetch static
assets no longer reaches renderErrorPage() upon hydration, so the loop
fixed by that commit is unrelated to this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
Upon failing to fetch static assets upon hydration, reload the page only
if some of the page's static assets don't exist anymore, i.e. a new
frontend was deployed. Upon a flaky connection, let it fail as before:
the server-side rendered page remains and, since Client Routing is
disabled, the next page navigation is a full page load.

The error thrown by `import()` is the same for a missing asset and for a
network error, so the page's `modulepreload` assets are checked with
HEAD requests: a non-2xx response or a response that isn't JavaScript
(e.g. S3's `403`, or the `200` HTML page of an SPA fallback) means a new
deployment, while a network error means a flaky connection.

This replaces waiting for the server to be reachable before reloading
(retry timer, `online` listener, server probe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
@brillout brillout changed the title fix: reload page if hydration fails to fetch static assets fix: reload page if hydration fails because of a new frontend deployment Oct 11, 2026
Comment thread packages/vike/src/client/runtime-client-routing/renderPageClient.ts Outdated
Comment thread packages/vike/src/client/runtime-client-routing/renderPageClient.ts Outdated
The first modulepreload link is the page's entry, and the hash in its
file name changes whenever any of the page's JavaScript changes (the
hash of a file includes the hashes of the files it imports). Checking
it alone is therefore enough: one HEAD request instead of one per
modulepreload link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
Instead of checking the response's status and content type, only check
whether the server responds at all. If it does, then either a new
frontend was deployed or the connection is back: either way, reloading
fixes the page. Upon a network error, we let it fail as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
@brillout brillout changed the title fix: reload page if hydration fails because of a new frontend deployment fix: reload page if hydration fails to fetch static assets Oct 14, 2026
Comment thread packages/vike/src/client/runtime-client-routing/renderPageClient.ts Outdated
claude and others added 4 commits October 15, 2026 07:30
@brillout

Copy link
Copy Markdown
Member Author

Review my last two commits.

showErrorPage() called disableClientRouting(), which asserts that the
error is a failure to fetch static assets. Any other error, such as the
page's code throwing while it's loaded on the client-side, or any error
in development, thus threw Vike's "You stumbled upon a Vike bug"
assertion instead of rendering the error page.

Now only the path that handles a failure to fetch static assets (a
blank page that was already reloaded) disables Client Routing.

New test: test/abort's /load-error page throws while it's loaded on the
client-side, and Vike renders the error page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD
- Vike now logs a single message upon failing to fetch static assets.
- Vike now checks the connection with a HEAD request to the page's URL
  instead of the page's entry, which the test doesn't block. So the SPA
  test now asserts that Vike doesn't check the connection at all:
  without the blank page rule, the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD

Copy link
Copy Markdown
Member Author

Reviewed 4f962c3 and c430ba9. There's one bug and a broken test, both fixed in 13591a8 and 46bb81f. The rest looks good.

Bug (4f962c3): showErrorPage() crashes on any error that isn't a failure to fetch static assets. It now calls disableClientRouting(), which asserts isErrorFetchingStaticAssets(err). So when the page's code throws while it's loaded on the client-side, the user gets Vike's "You stumbled upon a Vike bug" assertion instead of the error page, in development and in production.

Fixed in 13591a8: showErrorPage() no longer disables Client Routing. The one path that uses it for a failure to fetch static assets (a reloaded blank page) now calls disableClientRouting() itself. In development, errors are again handled like any other error, without disabling Client Routing.

No test covered this, so I added one to test/abort: its new /load-error page throws while it's loaded on the client-side. On c430ba9 the test fails with the assertion (preview). With the fix it passes in dev, dev:server, preview and prod.

Tests: CI is red on c430ba9 because "the page is reloaded only once" expects the log message that 4f962c3 removed. 46bb81f updates the expected message in both tests that check it.

Also, c430ba9 checks the connection on the page's URL, which the tests don't block. The SPA test would therefore keep passing without the blank page rule. It now asserts that Vike doesn't check the connection at all, and I checked that it fails without the rule.

c430ba9 (HEAD on the page's URL): 👍 Simpler, and it removes the injectFilter() edge case. Two trade-offs, both fine IMO:

  • With most servers, the check now runs the page's server-side rendering, including data fetching, instead of a static file lookup. It only happens upon a failure, so the extra load is negligible.
  • If static assets are served from another host (e.g. a CDN) that's down while the server is up, Vike now reloads once instead of not at all. isPageReload() still limits it to one reload.

4f962c3 (three actions): Reads much better. Together, the two commits make the client code 99 B smaller minified (63 B gzipped). Showing the error page for a reloaded blank page is a good fallback. Its own code may fail to load for the same reason, but then the page just stays blank: renderErrorPage() logs the error and stops, so there's no loop.

Nits I didn't change:

  • hardReload(url) ignores url and uses pageContext.urlOriginal. redirectHard(url) would be clearer.
  • The two comments about "the browser's 404 page": when the connection is down, the browser shows its offline error page, not a 404 page.
  • The new message says "server routing", while Vike's docs and the previous message write "Server Routing".

Generated by Claude Code

@brillout

Copy link
Copy Markdown
Member Author

97898cc => good?

Copy link
Copy Markdown
Member Author

With 7cc7abf, yes 👍

97898cc on its own wasn't: throw err skips Vike's error handling, since renderFirstPage() doesn't catch it. On the /load-error test page, the error page wasn't rendered. The hydration also never finished, so clicking a link did nothing (I checked in a browser). 7cc7abf's return false fixes both: the error page renders, and links work again.

I had made the same change locally. With it, test/abort passes in dev, dev:server and preview, and so do the playground's hydration tests.

Moving disableClientRouting() back into showErrorPage() is fine. The early returns now run first, so showErrorPage() is only reached for failures to fetch static assets, and the assertion holds.


Generated by Claude Code

@brillout

Copy link
Copy Markdown
Member Author

Review my last commits

Copy link
Copy Markdown
Member Author

f3410f6, 9c7b543 and 512955d are all good 👍 They fix the three nits, and none of them changes behavior. The tests only match the beginning of the log message, so the "Server Routing" casing doesn't affect them.

One optional nit: hardReload()'s comment says it aborts "without throwing nor logging any error", but disableClientRouting(err, true) does log the error with console.log(). Maybe "without throwing nor console.error()"?


Generated by Claude Code

@brillout

Copy link
Copy Markdown
Member Author

Then do one last round of review

Copy link
Copy Markdown
Member Author

Last round, on the whole diff against main at 512955d: nothing blocking.

I traced every path:

  • Development, or an error other than a failed fetch: the error page renders, as on main (test/abort).
  • Client-side navigation: a full page load of the target URL. An outdated navigation is skipped; it now gets checked before the failure is handled, whereas main hard-redirected to the outdated URL.
  • First render:
    • A page that was itself reloaded shows the broken page, or the error page if it's blank. Rendering the error page can't loop either.
    • A blank page reloads right away.
    • Otherwise, if the server responds, the page reloads; if the connection is down, the broken page stays.
  • No reload loop: every reload is guarded by isPageReload(). A hard load after a navigation leads to at most one more reload, on the next page.
  • Logging: disableClientRouting()'s assertion only runs for failures to fetch static assets now.

Optional suggestions, your call:

  1. Test for the client-side navigation path. No test covers it, although this PR changes it (the outdated-navigation check now runs first). I can add one to the playground: the next page's entry is gone, clicking the link does a full page load, and the page renders. To make sure the navigation (not the hover prefetch) hits the failure, the test would click the link without hovering it.
  2. Docs. /deploy-sync only describes the navigation case. It could add one sentence, e.g.: "If the page's own static assets fail to load upon hydration (e.g. the browser cached the HTML of the previous deployment), Vike reloads the page once." That sentence also clarifies that this reload doesn't contradict the page's "reloading … as soon as a new frontend deployment is detected is a no-go": it happens right after the page loads, before the user interacts with it. docs/public/llms.txt has no entry for that page.
  3. A slow connection check. On a flaky connection, the HEAD request can hang for a long time and then succeed. The page then reloads long after it was shown, maybe while the user is typing into a (non-hydrated) form. That's consistent with "the connection is back → reload", but it can be surprising. A timeout would avoid it, at the cost of not fixing the page when the connection comes back late: signal: AbortSignal.timeout(5000). In browsers without AbortSignal.timeout(), the call throws inside the try, so Vike doesn't reload. That's about 20 B gzipped. I'd lean towards leaving it as is.

Tiny nit: "counter productive" → "counterproductive".

CI on 512955d is still running.


Generated by Claude Code

@brillout

Copy link
Copy Markdown
Member Author
  1. A timeout sounds good, do it

Tiny nit: "counter productive" → "counterproductive".

Fix the typo

@brillout brillout changed the title fix: reload page if hydration fails to fetch static assets fix: improve handling when a static asset fails to load Oct 15, 2026
On a flaky connection, the HEAD request may hang and succeed much
later, which reloaded the page long after it was shown (e.g. while the
user is filling a form). After 5 seconds, Vike now considers the
connection down and lets it fail. In browsers without
AbortSignal.timeout(), the call throws inside the `try`, so Vike
doesn't reload.

New test: the connection check never gets a response, and the page
isn't reloaded.

Also fix a typo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdMeoWMUuZtWBoUosW7ZqD

Copy link
Copy Markdown
Member Author

Done in 63ed311:

  • Timeout: signal: AbortSignal.timeout(5000) on the connection check. After 5 seconds without a response, Vike considers the connection down, so the page isn't reloaded. Cost: +32 B minified / +26 B gzipped.
  • New playground test: the connection check never gets a response, and the page isn't reloaded. It takes about 5 seconds, and I checked that it fails without the timeout.
  • Typo: fixed.

Generated by Claude Code

Copy link
Copy Markdown
Member Author

27d7e89 (4 s timeout) 👍

4f4df97 removes all of this PR's tests: the six playground hydration retry tests and test/abort's load-error test. Nothing tests the new behavior anymore. The load-error test also caught both regressions in this PR (4f962c3's assertion and 97898cc's throw err), and it only takes about a second.

If run time is the concern, I can restore a smaller set: test/abort's load-error test, plus the playground's "reloaded if a new frontend was deployed" and "reloaded only once". Together they add about 2 s per production build and leave out the 4 s timeout test.

If the tests stay removed, the pages they used are now unused: test/playground/pages/spa/ and test/abort/pages/load-error/. Shall I delete them?


Generated by Claude Code

@brillout
brillout merged commit ef5704e into main Oct 15, 2026
25 checks passed
@brillout
brillout deleted the claude/intelligent-clarke-tgcdw7 branch October 15, 2026 11:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retry hydration upon flaky connection

2 participants