Ditching Puppeteer for 2.37x faster opengraph image generation
GitHub dynamically generates opengraph images for repositories and other pages such as issues and pull requests of a particular repository.
In their engineering blog, GitHub outlined their approach: they spin up a headless Chromium instance via Puppeteer, load a template with HTML & CSS, and take a screenshot of the DOM.
Although they managed to heavily optimize their render pipeline; bringing their initial ~2.25s render times down to an average of 280ms; the overhead of orchestrating a full browser engine remains substantial. I decided to measure exactly what that browser overhead costs in terms of time and memory by comparing it against decal (click on the link to read more about Decal), a simple image rendering engine made in Rust.
Setup
I used the github-card example from the decal repository. This example exactly replicates the GitHub opengraph image for the nasa/fprime repository. The scene is first rendered to an SVG which is then rasterized into a PNG image.
The decal implementation uses a macro to define the layout and styling directly in Rust:
let mut gh_card = decal! {
Column {
Row {
Column {
Scene(header("nasa", "fprime"))
Text("F' - A flight software and embedded systems framework")
.color(rgb(0x6e7681))
}
.gap(48)
.width(pct(70))
Image("https://avatars.githubusercontent.com/u/848102?s=200", 200.0, 200.0)
}
.justify_content(JustifyContent::SpaceBetween)
.gap(64)
.flex_grow(1.0)
// ... stars, forks, and language breakdown
}
.font_size(32.0)
.line_height(46.0)
.font_family("Mona Sans")
.background(rgb(0xffffff))
.size((1200, 600))
.padding(80)
};I compiled the github-card example in release mode on my MacBook Air M2 for benchmarking.
Benchmarking the first run typically includes a slight delay due to networking overhead when fetching the repository profile picture over the internet. The subsequent runs provide a stable metric for the rendering since the images are cached.

Rendering speed
I recorded the render time for decal and compared it to the generation metrics published by GitHub.
- GitHub (Puppeteer):
~280ms - Decal (cold start):
~118ms - Decal (warm cache):
~3.1ms
GitHub’s generator executes at roughly 280ms per image. Their Puppeteer pipeline originally took over 2.25s per image, but they later discovered that tweaking network idle settings allowed them to capture the screenshot much earlier without waiting for full page readiness.
On the very first iteration, decal took ~118ms. The vast majority of this render time is heavily dominated by network I/O (fetching the remote repository profile picture).
Once the engine caches the fonts and remote images, performance improves by a massive margin. For cached or locally available images, the average render time dropped to ~3.1ms.
Memory footprint
The overhead of the browser engine becomes much more obvious when we look at memory utilization.
- GitHub (Puppeteer):
> 512MB - Decal:
23.1MB peak
To achieve their 280ms speed, GitHub explicitly had to bypass Chromium’s low-spec device flag. On devices with less than 512MB of memory, Chromium runs processes sequentially to conserve RAM. By bypassing this, GitHub effectively requires allocating more than 512MB of memory per rendering instance to prevent throttling.
decal reached a peak memory usage of ~23 MB while rendering the same image 100 times in a loop.
Cost of rendering in browser
Using headless browsers for image generation is a popular pattern as it allows developers to use HTML and CSS for defining the image content. But orchestrating a browser to simply take a screenshot forces an enormous amount of unnecessary work.
By stripping out the browser and using a native layout engine, decal executes ~2.37 times faster and uses approximately ~22 times less memory. In a production environment, this means you could run dozens of concurrent decal renderers on a tiny 512MB VPS that would otherwise struggle to smoothly run a single Puppeteer instance.
Caching
Opengraph images like GitHub’s preview cards display dynamic metrics (stars, forks, open issues, etc.) that change frequently.
With an expensive Puppeteer pipeline, these images must be cached (often for hours or days) to prevent server overload, meaning users often see somewhat stale data when sharing links. Because decal’s cached rendering takes just 3.1ms, you can drastically reduce cache expiration times, or even completely avoid caching the final image for many endpoints; the renderer is fast enough to fetch the latest metrics and re-render the image on-the-fly for every single request.