Ramonda

Async work on the server

export class Profile extends Component<{ id: string }> {
  @state user: User | undefined;

  @mount async load() {
    if (this.user) return;          // restored from the server
    this.user = await getUser(this.props.id);
  }

  render() {
    return <article>{this.user?.name ?? "…"}</article>;
  }
}

renderToString waits for that fetch before serializing, so the name is in the HTML.

The trigger is just a returned promise

There is no new API. A lifecycle method that returns a promise has it awaited — on the server only. async @mount already returns one.

The asymmetry is deliberate. A server render is a request with a deadline and a single output; a client render is a live page that must paint before the data arrives, so there it stays fire-and-forget.

@mount runs on both sides — the blob is the memo

A shared @mount runs on the server and on the client. Fetching is what @mount is for, and the server render is when the data is fetched; skipping it on the client would mean a client-only navigation never fetches at all.

So it would fetch twice — except that whatever the server fetched is in @state, serialized, and restored before any client lifecycle runs. The guard is one line:

if (this.user) return;

That is deliberately not hidden behind a framework flag. Every "run once across a process boundary" abstraction has to answer what counts as already done, and only the component knows.

The drain is bounded

Waiting once is not enough: a resolved fetch writes state, which schedules a render, which builds components whose own @mounts fetch again. The server alternates — settle renders, take the new work, await it, commit — until a round produces nothing new.

Ten rounds, then it throws. Much smaller than the framework's render-loop bounds, because these are network round trips. A page needing ten sequential ones has a waterfall — a request whose response decides the next — and that is a problem to surface, not to absorb into a response time nobody can explain.

One failed fetch does not cost the page: the work is awaited with allSettled, so a component that failed renders its own failure and everything else renders normally.

Hydration does not destroy what the server rendered

The other half. A component whose output depends on something not there yet — the classic case is AsyncLoad with a cold module cache — would render a fallback on the client, disagree with the server's markup, and hydration would replace it. The reader watches finished content collapse into a spinner.

@deferHydration inverts that:

export class Panel extends Component {
  @deferHydration
  waitForChunk() {
    if (alreadyLoaded) return undefined;   // hydrate now
    return this.load();
  }
}

Return a promise and the client adopts this component's host and leaves everything inside it exactly as the server wrote it — no render, no comparison, no replacement — until the promise settles. Then the ordinary hydration path runs against those untouched nodes.

The rest of the page hydrates immediately; only this subtree waits. Updates to it are refused until it resumes, because a render there would diff against markup the diff does not know — but props that change meanwhile are picked up on resume, not lost.

A promise that never settles leaves the content on screen, non-interactive. That is the best available failure, and it is silent, so development reports it as RMD017.

AsyncLoad uses this for you. You need @deferHydration only when you write something with the same shape.

Next