EngineeringRendering

We Replaced a Headless Browser With a Python Renderer

Ridvay · August 4, 2026 · 10 min read

We Replaced a Headless Browser With a Python Renderer

Every poster and video Ridvay exports used to be rendered by a web browser. Not metaphorically — an actual Chromium, booted in a container, loading our editor, waiting for fonts, then taking a screenshot.

That sounds absurd until you think about why anyone does it. Then it sounds obvious. And then, once you measure it, it starts to look expensive.

Here's how we got from there to a renderer that draws the same poster in 70 milliseconds, and what it took to trust it.

Why a Browser Made Sense in the First Place

Our editor is a React app. A design is a JSON document — pages, elements, positions, fonts, colours, animation steps — and the editor turns that into DOM and CSS. When you drag a headline around, you're looking at the browser's layout engine doing its job.

So when a user hits Export, the safest possible renderer is the one that already agrees with what they're looking at: the editor itself. We load the app headless with a ?render=1 flag, hand it the design, wait for fonts and images to settle, and screenshot the stage.

This is correct by construction. There is no second implementation to drift out of sync, because there is no second implementation. Text wraps the way it wraps in the editor because it is the same code doing the wrapping.

The catch is everything a browser brings along for the ride. Process startup. A DOM. A CSS cascade. A layout engine that can handle floats, flexbox, grid, and writing modes. A compositor. For a design that is, in the overwhelming majority of cases, six rectangles and some text.

The Observation

We looked at what our designs actually contain. A typical poster is a background (solid colour or a linear gradient), a few text blocks, a couple of shapes, and an image or two. Absolute positions. No floats. No flexbox. No nested layout.

You do not need a browser to draw that. You need a 2D drawing library and an honest text measurement.

So we wrote one. It's called the rasterizer, it's a small Python service, and it sits in front of the browser service rather than replacing it.

The Rule That Makes It Safe

This is the part that matters more than any benchmark, so it goes before the benchmarks.

A fast renderer that is usually right is worse than no fast renderer at all. If our rasterizer draws a poster slightly differently than the browser would, nobody finds out. Both renders "succeed". The user gets a file. It's just the wrong file.

So the rasterizer runs a strict whitelist before it draws anything — and critically, it whitelists keys, not just element types. If a design carries a property the rasterizer doesn't implement, that design is not ours to draw. It gets handed to Chromium, byte for byte, and the caller never knows the difference.

Here's the failure mode that rule exists to prevent. Suppose an element carries "rotation": 45. A renderer that whitelists by type sees "this is a shape, I can draw shapes" and draws it unrotated. The browser draws it rotated. Two successful renders, one of them silently wrong, and no error anywhere in the system.

An unrecognised key is therefore an automatic decline, with the key named in the log line. Those log lines are a feature queue: they tell us exactly what real traffic is asking for that we don't yet support.

How a Design Becomes a PNG

The still path is short:

  1. Classify. Walk every page, element, and text line against the whitelist. Any unknown key, unsupported font, unsupported script, or out-of-range value produces a reason string. Any reason at all means the request is proxied to Chromium.
  2. Draw the background. A solid fill, or a CSS-style linear gradient.
  3. Draw each element onto its own layer. One RGBA layer per element, composited in z order. The per-element layer matters: it means element opacity blends against what's underneath instead of multiplying into it.
  4. Encode and upload. PNG by default, JPEG on request, straight to storage. The response is the same {imageUrl} shape the browser service returns.

Text is the part that needs care. There's no auto-fit in our format — whatever font size the design says is what renders — so the rasterizer loads the same TTF the browser loads, measures with it, and wraps the way CSS pre-wrap with break-word wraps. It also reproduces CSS half-leading, so the glyphs sit centred in their line box rather than flush to the top.

One detail that cost us real time to get right: variable fonts need every axis supplied, in the order the font declares them. Inter's axes are [opsz, wght]. Passing a single value sets the optical size and never touches the weight, which under-measured body copy by about 10% — enough to push a line of text onto a second line in the browser but not in the rasterizer.

The Gradient That Was 65% of a Render

The first profile turned up something embarrassing and delightful: over half the render time was the gradient background, drawn one pixel at a time in a Python loop.

A linear gradient is one-dimensional by definition. The colour depends on a single projected coordinate, so there are only ever 256 distinct values worth computing. Sample the ramp once into a lookup table, then let NumPy project it across the canvas.

1080×1350 gradient Time
Per-pixel Python loop 1,237 ms
256-entry LUT + NumPy 10.2 ms

122× on one function — the change that made the whole approach viable.

How a Design Becomes an MP4

Video is where the architecture gets interesting, because a browser is genuinely good at animation and we had to be careful about how we matched it.

Our animation model is small on purpose: each element gets an optional entrance, exit, and scene-long loop, and each page gets a transition to the next. The browser renders video by literally running that animation engine and screenshotting each frame.

To match it, the rasterizer is a line-for-line port of the editor's motion code — the timeline segmentation, the preset definitions, and the cubic-bezier easing solver, including the same 24-iteration bisection loop, so both engines land on the same eased value rather than merely a close one.

The performance trick is the sprite model. A naive frame renderer redraws every element on every frame. But an entrance animation doesn't change what an element is — it changes its opacity, position, scale, and clip. That's exactly what a browser does too: it lays out and rasterizes a layer once, then applies a transform per frame on the compositor.

So the rasterizer draws each element once at its natural size, caches that bitmap, and per frame applies the interpolated transform to the cached sprite. A 150-frame timeline costs seconds instead of minutes.

Frames then stream into ffmpeg as raw RGB over a pipe — libx264, preset medium, CRF 20, yuv420p, +faststart. Nothing is buffered but the encoder's own window, and no intermediate PNG files are ever written.

The Numbers

First, drawing. A 1080×1350 poster with a gradient background, four text elements, and two shapes, measured locally on an Apple M4:

Stage 1× (1080×1350) 2× (2160×2700, default)
Draw 26.8 ms 70.2 ms
PNG encode 68.8 ms 205.2 ms
Output size 132 KB 306 KB

Frame generation for video, same machine: 3.2 ms per frame, roughly 315 frames per second, after a one-time 24 ms sprite cache build.

Then the comparison that actually counts — the same design submitted to both renderers in the cluster, end to end, including upload:

Clip Rasterizer Chromium Speedup
1080×1350, 113 frames 6.0 s 24.0 s 4.0×
720×900, 77 frames 3.0 s 18.0 s 6.0×

Identical hardware, same submitted design, video matching frame for frame.

The Bottleneck Moved

Here's the result that surprised us most. At our default export settings — pixel ratio 2, PNG — drawing the poster takes 70 ms and compressing it takes 205 ms. PNG encoding is now three quarters of the work.

Switching that same image to JPEG at quality 82 costs 12.9 ms to encode instead of 205 ms, and produces a 185 KB file instead of 306 KB. End to end that's 83 ms versus 275 ms.

We spent our optimisation effort on drawing, and drawing stopped being the problem. Worth remembering the next time you're sure you know where the time goes.

How We Know It's Actually Right

A renderer that claims parity has to prove it, so we built a harness that submits the same design to both services and compares the outputs.

Comparing renders pixel-by-pixel is useless — antialiasing differs, and it would fail on every run for no reason. Instead the harness compares things that stay stable across two different rasterizers: the mean background colour around the edges, the fraction of drawn pixels inside each element's box, and a 32×32 grayscale reduction of the whole frame. Too coarse for antialiasing to matter, precise enough that a missing element, a font falling back, or a shifted block shows up immediately.

Current state: 13 of 13 elements within tolerance on stills, and 113 of 113 frames matching on video. On our most recent run, one animated element measured 0.238 ink coverage in the rasterizer against 0.240 in Chromium — a relative difference of 1%.

The rule we hold ourselves to is that no feature gets added to the whitelist without a parity fixture proving that specific case matches. Loosening the whitelist without that proof is how a service like this destroys the trust it just earned.

What We Still Send to the Browser

Plenty, and deliberately.

Right-to-left scripts go to Chromium, because the Pillow build we ship has no Raqm and would lay Arabic out left-to-right in isolated, unjoined forms. That isn't "slightly off" — it's a different piece of writing that still looks rendered, which is the worst possible failure. Per-letter animation presets go too, since they need a character-level engine we haven't built. So do matched-element morph transitions between pages, and any font we don't ship.

In recent production traffic the fast path handled around 83% of render submissions. Most of the remainder was our own parity fixture deliberately triggering a fallback; genuine declines ran under 6%. When we traced one, the cause turned out to be a design carrying durationMs where our format says duration.

That one was interesting, because the editor's own loader silently rewrites that field before Chromium ever paints it: unknown keys inside an animation step get dropped and the duration falls back to a default. So the browser wasn't rendering a 900 ms animation. It was rendering a 600 ms one. Declining that design was never the safe choice — only the slow one. We now run the same normalisation the editor runs, before classifying, and that class of decline is gone.

The Part Worth Copying

If you take one thing from this, it probably isn't the gradient lookup table.

It's the shape of the system: a fast path that is allowed to be incomplete, in front of a slow path that is always correct, with a hard rule that anything unrecognised falls through. We never had to make the rasterizer handle everything. We only had to make it honest about what it doesn't handle.

That's what made it shippable in the first place. A bug in the fast path makes a render slow. It cannot make one wrong.


Ridvay Engineering — measurements from the parity harness and a local profile, August 2026. The renderer described here is what draws every design made in Ridvay Studio, including the covers and videos on this blog.

Try Ridvay — the free AI design tool

Describe a poster, social post, flyer or slide and Ridvay generates a complete, editable design in seconds.

Open Ridvay Studio   ← All posts