Repository navigation
Remote functions fire during hydration with placeholder page.url, causing wrong server-side route resolution #15749
Description
Activity
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?Reacted by Hyunbin SeoIn 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:- the tenant has already been validated
- access control is enforced upfront
- remote functions can rely on a trusted
tenantSlugwithout 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.
Reacted by Keskas Aymenelliott-with-the-longest-name-on-github commented
on Apr 24, 2026 ContributorMore actionsYeah 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:
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).
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:<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
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:let previousTenantSlug = params.tenantSlug $effect(() => { if (params.tenantSlug === previousTenantSlug) return previousTenantSlug = params.tenantSlug void refreshAll({ includeLoadFunctions: false }) })
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.Reacted by Moritz Post and Hyunbin Seo- added a commit that references this issue
on Jul 22, 2026 - addedbugSomething isn't workingSomething isn't workingand removedbugSomething isn't workingSomething isn't working
on Aug 4, 2026
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 topage.url. At that momentpage.urlis still the placeholdernew URL('a:'), whosepathnameis the empty string"".The client helper
get_remote_request_headers(inruntime/client/remote-functions/shared.svelte.js) sendspage.url.pathnameas thex-sveltekit-pathnameheader. The server'srespond.jsthen does:…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
$derivedcontaininggetQuery(...)evaluates as soon as the component instantiates. Because<svelte:boundary>with apendingsnippet 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
$derivedre-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, orget_remote_request_headersshould fall back tolocation.pathnamewhenpage.url.pathname === ''.Suggested fix
In
get_remote_request_headers, fall back tolocationwhenpage.url.pathnameis empty:(
locationis always populated in the browser, and these helpers only run client-side.)Workaround
Gate the remote call until
page.url.pathnameis populated:Reproduction
src/routes/[slug]/data.remote.tssrc/routes/[slug]/+page.svelteSteps
/foo.400 No slug paramerror.DevTools → Network shows the failing request:
System Info
Severity
annoyance