Skip to content

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented Sep 16, 2025

This adds asynchronous SSR to an app that's using the latest version of Svelte (^5.39.3) and has opted in to the Svelte experimental.async option (and, ideally, the SvelteKit experimental.remoteFunctions option):

// svelte.config.js
export default {
  compilerOptions: {
    experimental: {
      async: true
    }
  },
  kit: {
    experimental: {
      remoteFunctions: true
    }
  }
};

This means that it's possible to use await anywhere in your app, without wrapping it in a boundary with a pending snippet. SSR will wait for all such 'naked' await expressions to resolve.

In turn, this means that it's practical to use remote functions, as it no longer forces you to put everything behind skeleton UI:

<script>
  import { echo } from $lib/data.remote';
</script>

<h1>{await echo('hello world!')}</h1>

Any remote function calls that occur during SSR will have their return values serialized and reused upon hydration, without an additional network round trip.

There are a few subtle bugs and caveats that make this unsuitable for production for the time being:

  • in some cases, an extra round trip is made on hydration — specifically, if the remote function call doesn't happen immediately (because it's in an {#if await condition} block or something) then it doesn't pull the value from the serialized data. The data is there, we're just not using it in that situation. Need to figure out how to do so while guaranteeing freshness
  • if a query is used during SSR, and that query is not exported from a .remote.ts file, it will have an incorrect cache key and the serialized data will be incorrectly sent to the client (this is an easy fix, will work on it next)
  • hydration is slightly buggy. What we want to happen is that the contents of a <svelte:boundary> will update together, but in the current implementation things will update as they are 'discovered'. In most cases, this does not matter, but it's observable if you have (for example) a {Date.now()} in multiple places
  • today, when you hover over a link SvelteKit will preload the dependencies for the next page on the assumption that you're about to click it; it does this by figuring out which load functions to call and calling them. There is, as yet, no equivalent for inline await expressions, because Svelte does not have an API for 'forking'

All these things will be fixed in due course.

Note to reviewers: other than the obvious changes in render.js, the big difference in this PR is that the virtual __sveltekit/paths module is no more. That's because we need different implementations of resolve(...) and asset(...) between client and server. On the server, we use the event store to determine the current event.url.pathname, so that we can construct a relative pathname (something that previously worked by setting base and assets immediately before render, and unsetting immediately afterwards) even if they are called after an await. On the client, this is unnecessary.

This change is long overdue. The virtual modules are a scourge, and I'd like to get rid of all of them. Having type safety and go-to-definition and everything else that goes with it is just so much nicer. All we need to do is have a few more define constants.


Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

Copy link

changeset-bot bot commented Sep 16, 2025

🦋 Changeset detected

Latest commit: 18051e1

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

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Minor

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

@svelte-docs-bot
Copy link

import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
import { set_building, set_prerendering } from '__sveltekit/environment';
import { set_assets } from '__sveltekit/paths';
import { set_assets } from '$app/paths/internal/server';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems pretty close $app/paths and i'm getting flashbacks from libraries messing with svelte runtime internals.
Do we have to prevent users from accessing internal somehow? maybe $internal would be one more step away from $app to make it clearer? Or is there a case for import maps here?

I do like the replacement of __sveltekit which struck me as odd before

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same as svelte/internal — accessible, but disallowed by types. I don't believe it's possible to not make it publicly accessible to someone who really wants to shoot themselves in the foot, but I also don't think that's a real problem. It won't show up as an auto-import option, and if you do import */internal in user code you'll be rewarded with a red squiggly

@Rich-Harris Rich-Harris merged commit f3b5bfb into main Sep 22, 2025
22 checks passed
@Rich-Harris Rich-Harris deleted the async-ssr branch September 22, 2025 16:35
@github-actions github-actions bot mentioned this pull request Sep 22, 2025
@wiesson
Copy link
Contributor

wiesson commented Sep 22, 2025

Btw, with this PR, the Error: Could not get the request store. This is an internal error. is back

// edit: Aaaand it has been fixed?! 🙌

@adriablancafort
Copy link

Amazing!

@amit13k
Copy link

amit13k commented Sep 24, 2025

I was trying to figure out if the following is achievable with the current async SSR / remote functions / svelte:boundary setup:

On initial load, certain blocks should be SSR’d (for SEO reasons), but on client-side navigation we’d prefer to quickly show skeletons instead of blocking. svelte:boundary feels like the right tool for this, but those aren't SSR'd.

I’ve experimented with conditionally awaiting remote functions based on whether navigation.complete is null or not, but error handling becomes pretty tricky that way.

Is there currently a good pattern for this kind of “SSR once, skeleton on client navigation” pattern, or is this something the framework might eventually provide an easier path for?

@Antonio-Bennett
Copy link

Antonio-Bennett commented Sep 24, 2025

@amit13k I'm pretty sure when you have a boundary the pending snippet is SSR'd so you'd use that

Edit:

Oh I think I actually understand what you meant: I think this actually is where $effect.pending comes into play?

@Rich-Harris
Copy link
Member Author

Right now, no. The same {#snippet pending()} that creates the skeleton UI on client-side state changes tells Svelte not to wait for the content during SSR. The two things are coupled.

One idea we've talked about is having a flag on the boundary — something like this:

<svelte:boundary ssr="block">
  <!-- ... -->

  {#snippet pending()}
    <p>loading</p>
  {/snippet}
</svelte:boundary>

This would allow you to explicitly say 'ignore the fact that this boundary has a pending snippet, I want to SSR the contents'.

I did think something like this might work...

{#snippet pending()}
	<p>loading...</p>
{/snippet}

<svelte:boundary pending={typeof window !== 'undefined' ? pending : null}>
	<p>{await delayed('hello')}</p>
</svelte:boundary>

...but it looks like we incorrectly interpret the presence of a pending attribute as a guarantee that it's not nullish. (cc @elliott-with-the-longest-name-on-github — we should probably fix this?)

@Ocean-OS
Copy link
Member

Would this just be checking for pending at runtime as opposed to compile time?

@Rich-Harris
Copy link
Member Author

Basically, yeah. If it's a {#snippet pending()} then we don't need a runtime check of course. If it's passed as a prop then we could maybe use an Evaluation to know if we need to check at runtime. If we do, then maybe a new method on Renderer is in order

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.

8 participants