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

> Kobweb has no server-side rendering and its issue for one has been open since 2022. This is an investigation into how much of it can be built from outside the framework, what it costs, and which part turns out to be impossible without a change in Compose HTML.

Page: https://kotlin.website/blog/server-rendering-kobweb · Published: 2026-08-24

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](https://github.com/varabyte/kobweb/issues/114), has
been open since February 2022 and is blocked on
[#113](https://github.com/varabyte/kobweb/issues/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](https://github.com/youndie/kobweb-ssr-experiment), running at
[kobweb-ssr.kotlin.website](https://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:

```text
/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](https://github.com/youndie/kobweb-ssr-experiment) ·
live: [kobweb-ssr.kotlin.website](https://kobweb-ssr.kotlin.website)
