Skip to content

Remote functions fire during hydration with placeholder page.url, causing wrong server-side route resolution #15749

Description

@mpost

A remote query invoked from a $derived (or any synchronously-evaluated reactive scope) during the initial hydration of a page can fire before SvelteKit has assigned the real URL to page.url. At that moment page.url is still the placeholder new URL('a:'), whose pathname is the empty string "".

The client helper get_remote_request_headers (in runtime/client/remote-functions/shared.svelte.js) sends page.url.pathname as the x-sveltekit-pathname header. The server's respond.js then does:

url.pathname = request.headers.get('x-sveltekit-pathname') ?? base;

…and find_route('') resolves the request to the wrong route — typically a layout group root rather than the actual page. Route params like [slug] end up missing, and any server-side logic that depends on them fails (400/404 in the best case; in the worst case, silent data leakage to a different tenant/scope).

Root cause

Three SvelteKit internals interact:

  1. runtime/client/state.svelte.js initializes page.url = new URL('a:'). new URL('a:').pathname === ''.
  2. runtime/client/client.js assigns the real page state via Object.assign(page, result.props.page) only after the hydration data load completes.
  3. runtime/client/remote-functions/shared.svelte.js reads page.url.pathname synchronously when building the request headers.

A $derived containing getQuery(...) evaluates as soon as the component instantiates. Because <svelte:boundary> with a pending snippet skips its async children on the server, the hydratable cache often misses → the client refetches → the fetch fires before the page-state assignment commits → empty pathname header → wrong route on the server.

The race window is small but reliably hit when the $derived re-runs (e.g., another async query resolving), because the second invocation races with the first one's pending state.

Expected behavior

Remote functions should not be able to fire with a placeholder page.url, or get_remote_request_headers should fall back to location.pathname when page.url.pathname === ''.

Suggested fix

In get_remote_request_headers, fall back to location when page.url.pathname is empty:

export function get_remote_request_headers() {
  return untrack(() => {
    const url = navigating.current?.to?.url ?? page.url
    const pathname = url.pathname || location.pathname
    const search = url.pathname ? url.search : location.search
    return {
      'x-sveltekit-pathname': pathname,
      'x-sveltekit-search': search,
    }
  })
}

(location is always populated in the browser, and these helpers only run client-side.)

Workaround

Gate the remote call until page.url.pathname is populated:

<script lang="ts">
  import { page } from '$app/state'
  import { getData } from './data.remote'

  let dataQuery = $derived.by(() => {
    if (!page.url.pathname) return undefined
    return getData({})
  })
  const pendingPlaceholder = new Promise<never>(() => {})
</script>

<svelte:boundary>
  {@const result = await (dataQuery ?? pendingPlaceholder)}
  <!-- ... -->
  {#snippet pending()}<p>Loading…</p>{/snippet}
</svelte:boundary>

Reproduction

src/routes/[slug]/data.remote.ts

import { query, getRequestEvent } from '$app/server'
import { error } from '@sveltejs/kit'
import { z } from 'zod'

export const getData = query(z.object({}), async () => {
  const { params } = getRequestEvent()
  if (!params.slug) error(400, 'No slug param — wrong route resolved!')
  return { slug: params.slug }
})

export const getOtherThing = query(z.object({}), async () => ({ ok: true }))

src/routes/[slug]/+page.svelte

<script lang="ts">
  import { getData, getOtherThing } from './data.remote'

  // Reading `.current` on another remote query causes the $derived to re-run
  // when it resolves — reliably re-firing getData during hydration and
  // racing against the real page.url being committed.
  let other = $derived(getOtherThing().current)

  let dataQuery = $derived.by(() => {
    void other
    return getData({})
  })
</script>

<svelte:boundary>
  {@const result = await dataQuery}
  <pre>{JSON.stringify(result)}</pre>
  {#snippet pending()}<p>Loading…</p>{/snippet}
  {#snippet failed(err)}<pre>{JSON.stringify(err)}</pre>{/snippet}
</svelte:boundary>

Steps

  1. Start the dev server and navigate to /foo.
  2. Hard-refresh.
  3. Observe a 400 No slug param error.

DevTools → Network shows the failing request:

GET /_app/remote/<hash>/getData?payload=...
x-sveltekit-pathname:        ← empty!

System Info

- SvelteKit: 2.57.1
- Svelte: 5.55.4 (with `compilerOptions.experimental.async: true`)
- `kit.experimental.remoteFunctions: true`
- Adapter: `@sveltejs/adapter-node`
- Node: 22.x
- Browser: Chrome 137

Severity

annoyance

Activity

  1. transferred this issue fromsveltejs/svelteon Apr 24, 2026
  2. dummdidumm commented on Apr 24, 2026

    @dummdidumm
    Member

    Question: What's the use case for using the url/params on the server? We're about to remove that capability from query, because we think these functions should be "pure" in the input-sense. So you would have to pass the params from the client, like getData(page.params). Does that also solve this issue?

  3. mpost commented on Apr 24, 2026

    @mpost
    Author

    In a multi-tenant SvelteKit application, it’s common to structure routes with a leading tenant identifier, e.g. /[tenantSlug]/overview. Following the current approach, this would imply that every remote function explicitly accepts and validates the tenantSlug.

    While this is technically correct, it introduces significant overhead, as the parameter needs to be threaded through all validation schemas and function signatures across the app.

    In our case, the tenantSlug is already validated centrally in the hooks.server.ts. From there, we attach it to the locals object, making it available to all remote functions. This ensures that:

    • the tenant has already been validated
    • access control is enforced upfront
    • remote functions can rely on a trusted tenantSlug without redundantly validating or passing it through every schema

    The downside of this approach is that remote functions accidentally invoked outside of a tenant-scoped context will fail at runtime, since they rely on locals being populated. This isn’t something that can be enforced at compile time, but in practice it hasn’t caused issues for us so far.

    With that being said we we do not require the params to be readable in the remote query itself.

  4. elliott-with-the-longest-name-on-github commented on Apr 24, 2026

    @elliott-with-the-longest-name-on-github
    Contributor

    Yeah I think this is running into the fundamental problem we've been talking about: Side-channel inputs to query functions (anything based on the URL, basically) are guaranteed to have staleness bugs, because the side-channel inputs aren't part of the remote function's key, which means the $derived / $effect around the remote function won't rerun when the URL changes... We could potentially use "did you use this" tracking for them like we do for load functions, but I'm not actually sure it's technically feasible, because we can't know before hitting the server whether you use these things, and therefore can't set them up as dependencies of the $derived... it's kind of a horrible chicken-and-egg problem.

    The only "foolproof" solution I can think of would be to allow the query to use side-channel inputs, but have to essentially declare them upfront somehow. That "somehow" is a gaping hole I don't know how to fill, lol, and I don't know that it could ever be better than "just pass the pathname as an argument to the query". Which in the case of @mpost would probably look like a module like this:

    import { page } from '$app/state';
    import { getDataWithParams } from '$lib/foo.remote.ts';
    
    export const getData = (args) => getDataWithParams({ params: page.params, ...args });

    ...except that you'd need to basically do that for every query in your app that requires the params. It's probably something you could rig up with a very simple Vite plugin, but I can't figure a way that we could do it for you (because you wouldn't want to do this for every query, just the ones you know need params/url/other side channel stateful inputs).

  5. mpost commented on Apr 24, 2026

    @mpost
    Author

    One approach we’ve adopted is to explicitly refresh all remote functions when navigating between tenants. We do this by calling refreshAll() whenever the tenantSlug changes.

    In /[tenantSlug]/+layout.svelte:

    <script lang="ts">
      import { afterNavigate, refreshAll } from '$app/navigation'
    
      afterNavigate(({ from, to }) => {
        const fromSlug = from?.params?.tenantSlug
        const toSlug = to?.params?.tenantSlug
    
        if (fromSlug && toSlug && fromSlug !== toSlug) {
          void refreshAll({ includeLoadFunctions: false })
        }
      })
    </script>

    This ensures that the UI always reflects the correct tenant-specific data after navigation, while remote functions continue to rely on the prevalidated tenantSlug from locals.

    I’m not entirely sure this is the best approach, though. An alternative would be to react directly to changes in params.tenantSlug within the layout:

    let previousTenantSlug = params.tenantSlug
    $effect(() => {
      if (params.tenantSlug === previousTenantSlug) return
      previousTenantSlug = params.tenantSlug
      void refreshAll({ includeLoadFunctions: false })
    })
  6. dummdidumm commented on Apr 24, 2026

    @dummdidumm
    Member

    The ability to read the url and params in handle could stay. That would give you the desired escape hatch if you really need to. And you bring up a good point - even if we made params/url non-readable, you can still introduce side channels via locals - and we can't/won't block that because locals is not guaranteed to be used in a side-channel-way.

  7. added
    bugSomething isn't working
    and removed
    bugSomething isn't working
    on Aug 4, 2026
  8. added theissue type on Aug 4, 2026
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

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions