Repository navigation
Remote functions fire during hydration with placeholder page.url, causing wrong server-side route resolution #15749
Description
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:
runtime/client/state.svelte.jsinitializespage.url = new URL('a:').new URL('a:').pathname === ''.runtime/client/client.jsassigns the real page state viaObject.assign(page, result.props.page)only after the hydration data load completes.runtime/client/remote-functions/shared.svelte.jsreadspage.url.pathnamesynchronously 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
- Start the dev server and navigate to
/foo. - Hard-refresh.
- Observe a
400 No slug paramerror.
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 137Severity
annoyance
Activity
dummdidumm commentedon Apr 24, 2026
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, likegetData(page.params). Does that also solve this issue?mpost commentedon Apr 24, 2026
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 thetenantSlug.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
tenantSlugis already validated centrally in thehooks.server.ts. From there, we attach it to thelocalsobject, making it available to all remote functions. This ensures that:tenantSlugwithout redundantly validating or passing it through every schemaThe 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.
elliott-with-the-longest-name-on-github commentedon Apr 24, 2026
Yeah I think this is running into the fundamental problem we've been talking about: Side-channel inputs to
queryfunctions (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/$effectaround the remote function won't rerun when the URL changes... We could potentially use "did you use this" tracking for them like we do forloadfunctions, 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:
...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).
mpost commentedon Apr 24, 2026
One approach we’ve adopted is to explicitly refresh all remote functions when navigating between tenants. We do this by calling
refreshAll()whenever thetenantSlugchanges.In
/[tenantSlug]/+layout.svelte:This ensures that the UI always reflects the correct tenant-specific data after navigation, while remote functions continue to rely on the prevalidated
tenantSlugfromlocals.I’m not entirely sure this is the best approach, though. An alternative would be to react directly to changes in
params.tenantSlugwithin the layout:dummdidumm commentedon Apr 24, 2026
The ability to read the url and params in
handlecould stay. That would give you the desired escape hatch if you really need to. And you bring up a good point - even if we madeparams/urlnon-readable, you can still introduce side channels vialocals- and we can't/won't block that becauselocalsis not guaranteed to be used in a side-channel-way.breaking: error on `event.url`, `event.params` and `event.route` acce…