Rendering

Rendering images on the server without a headless browser

We needed to turn structured content into finished images on the server: fixed canvas, real typography, a set of visual templates, hundreds of renders a day. The obvious answer is a headless browser – lay the template out in HTML, screenshot it, done. We ran that for a while and then replaced it.

What headless Chrome cost us

Nothing about it was broken exactly, but everything about it was heavy. A browser instance is a few hundred megabytes of resident memory, so concurrency is capacity planning rather than a number you pick. A cold start is measured in hundreds of milliseconds before your own code does anything. The container image grows by an order of magnitude, and it needs system fonts, which means the same template renders subtly differently depending on what the base image happens to ship.

The last point is the one that actually hurt. Rendering was not deterministic across environments. A template reviewed on a developer machine could come out with different line breaks in production because a font fell back. Debugging a one-pixel difference in a screenshot is not work anyone wants.

Satori: a layout engine, not a browser

Satori takes an element tree and produces an SVG. It implements a subset of CSS – enough to lay out a poster-style composition – and nothing else. There is no JavaScript execution, no network, no DOM. We rasterise the SVG to PNG in the same service.

The immediate wins were the ones you would expect: memory per render dropped from hundreds of megabytes to single-digit, cold start effectively disappeared, and a frame takes tens of milliseconds instead of hundreds. The container went back to being a normal Node image.

The win we did not anticipate was determinism. Fonts are passed to the renderer as buffers, so text measurement depends on bytes we ship, not on what the operating system has installed. The same input produces the same pixels on a laptop and in production. That single property removed an entire category of bug reports.

BEFORE – HEADLESS BROWSER HTML + CSS Chromium screenshot PNG ~300 MB RSS · ~800 ms cold depends on system fonts AFTER – SATORI element tree Satori SVG PNG ~8 MB · ~40 ms fonts shipped as buffers
The same job, minus the browser. Numbers are from our own service on a 2‑vCPU container.

What you give up

Satori's CSS subset is genuinely a subset, and you find its edges by hitting them:

Porting our templates was mostly a mechanical rewrite into flex, with a handful of layouts that had to be rethought rather than translated. Ten templates took a few days.

The part that turned out to matter

Working inside a small, explicit subset made templates behave like data rather than like web pages. They became small enough to read in a diff, and different enough from application code that a designer could reason about one without tracing a component tree. When we later moved every template onto a shared set of style tokens, that rewrite was tractable precisely because each template was fifty lines of flex and token references instead of arbitrary CSS.

If your server-side rendering needs a real browser – arbitrary user HTML, scripts, unknown layout – use a browser. If it renders your own templates with your own fonts, a browser is a very expensive way to measure text.
← All notes