Skip to content

fix(qwik-router): make loader response caching opt-in (drop 120s default expires) - #8900

Open
blakeley wants to merge 1 commit into
QwikDev:mainfrom
blakeley:fix/router-loader-expires-opt-in
Open

fix(qwik-router): make loader response caching opt-in (drop 120s default expires)#8900
blakeley wants to merge 1 commit into
QwikDev:mainfrom
blakeley:fix/router-loader-expires-opt-in

Conversation

@blakeley

@blakeley blakeley commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #8895

The problem

routeLoaderQrl() defaults expires to 120 seconds:

// packages/qwik-router/src/runtime/src/route-loaders.ts
loader.__expires = expires ?? 120_000; // 2 minutes

sendLoaderResponse() turns any non-zero __expires into a real HTTP caching header:

// packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts
if (loader?.__expires && loader.__expires > 0) {
  requestEv.cacheControl({ maxAge: Math.ceil(loader.__expires / 1000), private: true });
}

So every q-loader-{id}.{hash}.json response ships Cache-Control: private, max-age=120 unless the author opted out.

Read-after-write staleness

  1. User submits a routeAction$ / server$ that mutates a row.
  2. Within the next 120 seconds the app does a client-side navigation back to (or into) a route whose loader reads that row.
  3. The client's loader fetch is answered by the browser HTTP cache — zero network, no request reaches the server — so the page renders the pre-mutation value.
  4. It self-heals only after the 120s window expires, or on a hard reload with cache disabled.

Because the fetch never leaves the browser, this is invisible in server logs and in the Network panel's "from disk cache"-blind reading; it looks like the mutation silently failed.

Repro with header proof and a Playwright demo of the stale render: https://github.com/blakeley/loader-default-120s-cache

The fix

Default expires to 0, making loader HTTP caching opt-in. The response writer already gates on __expires > 0 (quoted above), so 0 means no Cache-Control header at all — the browser revalidates every loader fetch and a mutation is visible immediately on the next one. Authors who want caching pass expires explicitly, exactly as documented.

This restores Qwik 1's effective behavior: loader values were never HTTP-cached there, so apps upgrading to v2 inherit a caching policy they never asked for. Per-loader expires remains fully available and unchanged.

Notes

  • The docs already say the default is 0. The options table in packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx lists expires as 0 (static) — the code and the documentation disagree today; this PR makes the code match. Docs wording is updated to describe caching as opt-in and to warn about what expires implies.
  • expires also drives the client signal's expiry (expires: loader.__expires is passed to the loader's AsyncComputedSignal), so the same constant currently also marks loader values stale after 2 minutes. With poll: false (the loader default) that only triggers a refetch on next read — and that refetch was itself served from the 120s HTTP cache, so the two windows were coupled. Both become opt-in together.
  • The separate in-memory fetch dedup cache is untouched. LOADER_FETCH_CACHE_TTL = 5_000 in route-loaders.ts is its own constant for coalescing concurrent/duplicate loader requests; it is not tied to expires and this PR leaves it alone.
  • SSG: worker-thread.ts pre-generates static per-loader .json files for loaders with __expires === 0. With the new default, plain loaders now get that treatment — which is what the docs already describe for expires: 0. Worth a maintainer eye if that was intentionally reachable only by opting in.
  • Added a unit test pinning the default (does not cache loader responses unless expires is given); the existing test that asserts an explicit expires still maps to Cache-Control is unchanged and passing. No test asserted the 120s default.
  • Changeset included (@qwik.dev/router patch).

If the 120s default is intentional

Happy to be told this is deliberate — a default TTL does cut loader traffic meaningfully. If so, I'll pivot this PR to documenting it prominently instead: call it out in the v1 → v2 migration guide (it is a silent behavior change for anyone porting an app that mutates data), fix the options table that currently claims 0, and add the "set expires: 0 on anything a user can mutate" guidance. Your call which way to take it.

@blakeley
blakeley requested review from a team as code owners August 1, 2026 00:19
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d48c19a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@qwik.dev/router Patch
eslint-plugin-qwik Patch
@qwik.dev/core Patch
create-qwik Patch
@qwik.dev/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@qwik.dev/core

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@8900

@qwik.dev/router

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@8900

eslint-plugin-qwik

npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@8900

create-qwik

npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@8900

@qwik.dev/optimizer

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/optimizer@8900

@qwik.dev/devtools

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/devtools@8900

commit: d48c19a

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.

[🐞] v2: routeLoader$ defaults to expires=120000 — browser-cached loader JSON serves stale data after server-side mutations

1 participant