firsthand

ADR-0027: Server rendering is a second compiler target, and hydration adopts

Status: accepted · 2026-09-22 · extends ADR-0005, ADR-0009, ADR-0022

Problem

A page that arrives empty and fills itself in is slower to read, worse to index and worse to share. Server rendering fixes that, and hydration is what stops the browser from throwing the result away.

Firsthand had neither, and the way it renders makes both harder than they are in a framework with a virtual DOM. There is no tree of descriptions to serialise: the compiler turns markup into a <template> and a set of parts that write into it, and both of those are DOM objects. A server has no DOM.

Constraints

Options

1. Render to a DOM emulation on the server, then serialise it. Works immediately, needs no compiler work, and is what a framework that has run out of ideas does. It is also five to ten times slower than building a string, and it makes every DOM quirk a dependency of the server.

2. A virtual DOM for the server only. Two renderers, two sets of bugs, and the component model would have to be expressible in both.

3. A second compiler target. The compiler already turns JSX into calls against a named protocol (@firsthandjs/dom/internal). Emit the same shapes against a different protocol — @firsthandjs/server/internal — where markup is a string rather than a tree. Chosen.

Chosen design

The compiler has an ssr mode

One flag, set by the bundler. Vite already tells a plugin which build it is running, so an application configures nothing:

<p class="lead">Hello, {name}!</p>

DOM      template("<p class=\"lead\">Hello, <!>!</p>") + insert(el, () => name, marker)
server   ssr(["<p class=\"lead\">Hello, <!--[-->", "<!---->!</p>"], child(name))

The same traversal, the same planChildren, the same attribute rules. What differs is what gets emitted at the end.

Hydration adopts, and the markup says where the seams are

template() in a hydrating render hands back a node the server sent instead of a clone, and a dynamic child finds its content in place instead of creating it. That needs one thing the markup does not otherwise say: where each dynamic child begins. Its content has a length the template does not.

So the server marks it. <!--[--> opens a region; the marker the browser’s own template has at that position closes it. Two consequences, and they are the whole of the design:

A dynamic child that is the whole of its element’s content gets no marker at all — where the element’s children start is where the region starts, and the client can see that for itself. That is the common case (<td>{id}</td>), and leaving the marker out brought the markup down to exactly the size of a document with no hydration bookkeeping in it at all: byte for byte what Vue and React send, and 7 % smaller than Solid.

The markers are in the server’s markup only. A build without SSR emits neither them nor the navigation helpers.

Hydration is its own entry point

@firsthandjs/dom/hydrate, not @firsthandjs/dom. Nothing in the render path imports it: template and insert read a holder in claim.ts that stays empty, so a bundle that never imports hydration never contains it. What is left behind in the ordinary path is one property read and one comparison, in two places.

That is what keeps the second constraint: the runtime budget is unchanged, and hydration is 2.0 kB gzip that only an application with a server downloads.

Data crosses the wire under the name it already has

A resource belongs to its call site (ADR-0022), so it has no key to be serialised under. A named resource does: persist is a name the application chose, and a name is exactly what state transfer needs.

const notes = useResource(({ signal }) => listNotes(signal), { persist: 'notes' });

The server fills a createMemoryStorage() under that name, renderToStringAsync waits for it via store.settle(), storage.dump() goes into the page, and the browser starts from createMemoryStorage(sent) — where the resource finds its value during its first run, which is what makes the first paint the markup rather than a spinner replacing it.

A resource without a name still renders on the server. It arrives in the browser unanswered, because there is nothing to put it under. That is a decision the call site makes, in one word, and can see.

What a server does not do

A spread means the same thing on both sides

Everything else in this document is about markup the compiler emits, where the two targets are generated from one description and parity.test.ts compares the trees. A spread is not that: its names and its shapes arrive at runtime, so the two implementations are written twice and can drift — and they had. A class record toggled on === true on the server and on truthiness in the browser, a style record was skipped server-side entirely, and prop:/attr: were written into the markup as if they were attribute names.

The rule is that a spread is a hydration surface, not a serializer: what the server writes has to be what the browser would have built, because otherwise hydration is a correction rather than a comparison — a page that arrives wrong and moves once the runtime catches up. The two tables are asserted against each other in spread-parity.test.ts, one table in two files.

A spread is also the only place where a name in the markup comes from data the application may not have written, which is why on… is refused there. That is in the DOM reference.

Performance

Measured by benchmarks/ssr/run.mjs and benchmarks/ssr/hydrate.mjs, with the same rules the browser benchmark keeps: the output is compared before anything is timed, the frameworks are interleaved in one process, the build is the production build on every side, and the loser is published.

1 000 rows, each with an id, a link, a class that depends on state and a handler:

  Firsthand Solid Vue React
render to markup 0.171 ms 0.205 ms 15.02 ms 199.67 ms
markup size 222 802 B 238 694 B 222 802 B 222 802 B
hydrate 4.12 ms 4.62 ms 10.48 ms —

React’s hydrateRoot schedules its work rather than doing it, so a number taken the same way would be the time to start hydrating. It is left out rather than reported as something it is not.

Four changes account for almost all of the distance, and each was found by profiling rather than by guessing:

Change Server render
baseline 1.381 ms
props not frozen on a server 0.741 ms
pure props written as values, not accessors 0.234 ms
escaping in one pass, behind a native test 0.205 ms
scopes created only when needed 0.189 ms

Object.freeze per component was 46 % of a server render on its own.

Memory

A server render holds nothing after it returns: the root owner is disposed, so a computed that subscribed to a module-level signal is unsubscribed rather than left pointing at it from the next request’s graph. createMemoryStorage is one object per request and is the caller’s to drop.

DX

The application is the application. examples/ssr renders the same src/app.tsx on both sides; the only files that know a server exists are the two entry points and four lines of vite.config.ts.

Where the two sides disagree:

Rejected alternatives