Skip to content

Preloaded redirect results are cached and replayed, causing stale redirects and redirect loops #16484

Description

@Hoffs

Describe the bug

When a link is data-preloaded (data-sveltekit-preload-data, on hover or tap) and the target route's load returns a redirect(...), the client stores that redirect result in its preload cache (load_cache). On the subsequent client-side navigation the cached redirect is replayed without re-running the load, so the redirect decision is never re-evaluated against current server state.

If the redirect target itself redirects back — two routes gating on opposite conditions — the client's redirect-follow recursion never commits a page, and load_cache is only cleared on a committed navigation. So the stale redirect is replayed every cycle until the 20-redirect limit throws Error: Redirect loop. This loops even when a fresh evaluation would resolve (i.e. after the gating condition has changed), because the cached leg never re-fetches.

Reproduction

Two routes gating on opposite states of a server-side flag, plus a preloaded link into one:

src/lib/state.js

export const state = { selected: false };

src/routes/dashboard/+page.server.js

import { redirect } from '@sveltejs/kit';
import { state } from '$lib/state.js';
export function load() {
  if (!state.selected) redirect(303, '/select');
  return {};
}

src/routes/select/+page.server.js

import { redirect } from '@sveltejs/kit';
import { state } from '$lib/state.js';
export function load() {
  state.selected = true;      // "select", then go to the gated page
  redirect(303, '/dashboard');
}

src/routes/start/+page.svelte

<a href="/dashboard" data-sveltekit-preload-data="hover">go to dashboard</a>

Steps:

  1. Visit /start.
  2. Hover or tap the link. This preloads /dashboard/__data.json while selected is false, so /dashboard's load redirects to /select, and that redirect result is cached for /dashboard.
  3. Click the link.

Expected: /dashboard → /select (sets selected = true) → /dashboard renders. One bounce, resolves.

Actual: /dashboard replays the cached redirect to /select; /select redirects back to /dashboard; the cached /dashboard redirect is replayed again — never re-fetched, so it never observes selected === true — and it loops until Error: Redirect loop.

Without the preload (data-sveltekit-preload-data="off", or a full-page navigation) it resolves in one bounce — confirming the cached redirect is the cause, not the mutual redirect itself. A hard refresh also "fixes" it, because a fresh document load starts with an empty client cache.

Root cause (packages/kit/src/runtime/client/client.js, verified on 2.70.0)

  • _preload_data caches the load_route result and only discards it when it is a loaded result with an error (// Don't cache errors, because they might be transient). A redirect result is kept.
  • load_route short-circuits on a cache hit: if (load_cache?.id === id) { … return load_cache.promise; } — returning the cached redirect with no fetch.
  • In navigate, the redirect branch (redirect_count < 20) recurses and returns before the load_cache = null on the commit path — so a redirect-only chain never clears the cache.

Expected behaviour

A preloaded redirect result shouldn't be replayed as a navigation decision. The most consistent fix looks like discarding redirect results in _preload_data the same way errors are already discarded (a redirect is equally transient / server-state-dependent, and isn't renderable data), or otherwise re-evaluating rather than replaying it on consumption.

Severity / scope

Any app with two routes gating on opposite conditions (e.g. an onboarding/auth gate: a page requires X, the gate requires not-X) plus a preloaded link into one of them can hit an unrecoverable redirect loop until a hard refresh. Reproduces through @sveltejs/kit@2.70.0.

Related: #16192 (query caches a redirect response) — same theme in a different subsystem.

System Info

  • @sveltejs/kit: reproduced on 2.61.1 and verified still present through 2.70.0

Activity

  1. ondraulehla commented on Jul 25, 2026

    @ondraulehla

    Heads up that this is on version-3 too, and a bit worse there. _preload_data stores every result in load_cache, and load_route hands it straight back when the ids match ("the preload becomes the real navigation") without re-running load, with nothing nulling the cache afterwards. discard_load_cache only fires on invalidation, refreshAll, a different intent id, a rejected promise and a fork mismatch, never on the result type, so a preloaded redirect gets replayed exactly like it does on main.

    The extra bit: v2's // Don't cache errors, because they might be transient guard didn't survive the rewrite, so on v3 preloaded errors are cached as well.

    I have the main fix in #16486. Happy to open the version-3 counterpart if you want it.

  2. Nic-Polumeyv commented on Jul 26, 2026

    @Nic-Polumeyv
    Member

    v2's // Don't cache errors, because they might be transient guard didn't survive the rewrite, so on v3 preloaded errors are cached as well.

    This part isn't right, preload errors reject on v3 and that discards the cache (client.js:767). The redirect half is right. The cache is never discarded based on result type, and the redirect recursion in navigate() returns before the cache reset is reached, so a preloaded redirect replays just like on main.

  3. ondraulehla commented on Jul 26, 2026

    @ondraulehla

    Correcting myself on the version-3 part: navigate() sets load_cache = null when a navigation commits, so the plain replay I described doesn't apply there. A loop would need routes that keep redirecting into each other with nothing ever committing, and I haven't tested that, so treat my earlier comment as unverified for v3. The behaviour on main described in the issue stands.

  4. added
    bugSomething isn't working
    and removed
    bugSomething isn't working
    on Aug 4, 2026
  5. added theissue type on Aug 4, 2026
  6. teemingc commented on Aug 25, 2026

    @teemingc
    Member

    The actual issue is that we're reusing the same load cache on the second navigate call, which is really suppose to grab a fresh load result, but doesn't because the load ID is never updated

    if (!action_result && load_cache?.id === id) {

    We could delete the load cache before that happens, specifically at

    if (navigation_result.type === 'redirect') {
    but that just makes the preload redundant

    I have a feeling that the better solution would be to see the redirect through (similar to what the native fetch does by default) and cache the final result instead so that the preload actually makes the navigation instantaneous. @dummdidumm any idea why we never did that in the first place and have this type: redirect load result?

  7. dummdidumm commented on Aug 25, 2026

    @dummdidumm
    Member

    I honestly can't remember anymore. I think it's because we always thought of the loaded state as "this is for the page you requested" and not "the end result"

  8. added a commit that references this issue on Aug 27, 2026
    a373fff
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions