Server-side rendering for Kobweb: what is reachable from outside

Kobweb builds sites out of @Composable functions on top of Compose HTML. It has no server-side rendering: a page arrives as a shell or as a build-time snapshot, and the client rebuilds the tree from scratch. The issue asking for it, #114, has been open since February 2022 and is blocked on #113, which is about hydration.

The question this set out to answer was narrow: how much of server-side rendering can be built from outside Kobweb and outside Compose HTML — patching neither — and what does it cost? The answer is most of it, at roughly a second per page and 144 MiB per renderer worker, with one part that turns out to be unreachable for a structural reason rather than an unfinished one. Working stand: kobweb-ssr-experiment, running at kobweb-ssr.kotlin.website.

Method

The stand is a real Kobweb 0.25.1 site — Compose HTML 1.11.1, Kotlin 2.4.10 — on unmodified published artefacts, with 45 assertions in a script that brings the chain up and tears it down. Numbers are from one MacBook Air M1 against a release bundle of 845 KB: useful for comparing configurations to each other, not for capacity planning.

Two rules mattered more than any single result. Assertions compare content, never status codes — a renderer that has lost every stylesheet still answers 200, and so does a page rendered for somebody else's request. And a suite that has never failed proves nothing: five deliberate mutations, one per finding, confirm each assertion fails where it should.

Where the upstream is

Worth settling first: is any of this about to become unnecessary? In August 2026 JetBrains published an exploration of server-side rendering for Compose HTML, describing a renderToString that composes once and serialises. It calls itself "an exploration instead of an official commitment", and the tracker agrees — there is no YouTrack issue for a JVM target at all, and the nearest neighbour, CMP-4461 (wasmJs support, strictly the easier job) has been open and unassigned since March 2024.

One artefact-level detail regardless: html-core-jvm is already published and is an empty jar. A JVM source set depending on html-core resolves today and gives you nothing, failing at every unresolved reference rather than at dependency resolution.

Where a plugin can take over a route

Kobweb exposes KobwebServerPlugin, which hands over the raw Ktor Application. Registering a route through it is not enough, and the difference only shows in one of the two layouts:

how the plugin claims the route fullstack layout static layout
routing { get("/page") } wins loses to the exported page
intercept(ApplicationCallPipeline.Plugins) wins wins

In the static layout an exported page owns the same path as a constant route, both are equally specific, and plugins are configured after Kobweb's own routing — so the page wins. The interceptor runs before routing gets the call and is unaffected. Measuring only the fullstack layout would have confirmed the wrong conclusion, which it did for an afternoon.

It also produced the sharpest methodological result here. The first measurement said the plugin won in both layouts, and it was a false positive: kobweb export drives a real browser against the running server, so a plugin loaded during the export gets its replies baked into the static files. The response carried the plugin's marker because it came from a file containing yesterday's plugin output. Caught by a size mismatch — 205 bytes where 39 were expected — not by the marker the assertion was reading.

What a render costs

The renderer is a separate process holding a headless Chromium, reached over HTTP. Per page, one worker, cache off:

/ssr        0.813 s median (n=8, 0.774–1.276)
/findings   1.171 s median (n=8)
cache hit   3.1 ms median (n=6)

Memory is linear in workers and bounded by memory rather than cores — about 68 MiB fixed plus 144 MiB per worker, measured by cgroup at one, two and four workers (212, 356 and 643 MiB under load). The qualifier "by cgroup" is doing real work in that sentence: the first version of this figure said 342 MiB per worker, from summing RSS across the renderer's process tree. RSS counts the pages Chromium shares between its processes once per process, so the sum overstated by 2.4× — and in the direction that makes "expensive" look convincing. The deployment had been sized from it and was reserving twice the memory it needed.

Two recursions

The renderer produces a page by asking the same server for it, which makes the arrangement self-referential twice. In routes that is obvious and a header fixes it: without a marker to stand aside for, the plugin intercepts the renderer's own fetch and asks the renderer again.

In threads it is neither obvious nor shaped like a deadlock. Kobweb runs on Netty; waiting for the renderer on an event-loop thread starves the loop the renderer needs in order to be answered. Eight concurrent requests took 20.09 s — every one exactly the client timeout — and read from outside as "slow under load", status 200, pages arriving. On a separate dispatcher: 2.889 s. A cache hides this entirely, since it appears only cold and under load, which is why every measurement afterwards was taken with the cache in both positions.

What was refuted

Two conclusions did not survive a second measurement — three counting the memory figure above — and they are more informative than the ones that did.

Rendering in Node under jsdom was rejected for losing 61 CSS rules — a third of the stylesheet. It was not losing any of them. The baking script that writes CSSOM rules back into <style> elements iterated document.styleSheets by index, and that collection is live: assigning innerHTML re-creates a sheet and, under jsdom, moves it, so the loop skipped entries. Same page, same moment, by live index: 348, 0, 62452, 0, 1141; over a snapshot: 348, 3900, 62452, 1353, 1141. With that fixed, jsdom matches Chromium at 342 CSS entries to 342. The decision to keep the browser renderer stands, but on much weaker grounds — no settling signal, CSS.supports answered by a guess, and no memory advantage. The script is a verbatim copy of Kobweb's own export script, so the same fragility sits there, unexposed because Chromium does not reorder.

What cannot be done from outside

Hydration, and the fallback plan for it does not exist either. A composition can never adopt a node already in the document: DomNodeWrapper.insert only inserts or appends and the factory in TagElement always creates a fresh element, so composing into a non-empty root inserts alongside rather than overwriting. Nor can anything diff the two trees — attributes, inline styles, classes and listeners go straight into the browser node inside TagElement's update block and never pass through the Applier, which therefore sees insert, remove and move and nothing else. The cost of the alternative is measured rather than assumed: 11 nodes removed, the first 312 ms after the head is parsed, replaced by markup identical to what it replaced.

The useful form of the ask upstream is therefore not renderToString, which is already on JetBrains' list and is the easier half. It is either routing node mutations through the Applier, or letting ElementBuilder return an element that already exists. Either one unblocks Kobweb #113 and Kilua's "hydration implemented in a simple way" at once.

Per-page <head> is the other gap, and it is Kobweb's rather than this experiment's: its own static export gives every page the same <title>, and there is no API in the frontend that sets one per page. For server rendering that is half the point missing — an indexer reads the head first.

Limitations

  • Authenticated pages are refused rather than rendered. Carrying cookies means a personal render: either uncached, at a second per request per visitor, or cached per user, where one wrong key hands someone another person's page. Kilua draws the same line and says so.
  • The query string and Accept-Language reach the render and are part of the cache key; nothing else does. The route table is read from a build output (frontend.json) that a deployed site does not ship, so putting it where a deployed server can read it is Gradle-plugin work.
  • Everything is measured against Kobweb 0.25.1 and Compose HTML 1.11.1 on one laptop. The numbers compare configurations to each other; they are not capacity figures.
  • The renderer image is 1.88 GB — Chromium installed through the same Playwright jar the renderer runs with, which is less than half of Playwright's own image but still a slow first pull.

Source: github.com/youndie/kobweb-ssr-experiment · live: kobweb-ssr.kotlin.website