Skip to main content

Social cards in pure Rust: no headless Chrome

· 7 min read
Founder, Dragon Fractal · ex-AWS engineer

Cloud Cost Analyzer (CCA) lets you share a read-only scan as a link. Someone drops that link in Slack or on LinkedIn, and instead of a bare URL I wanted it to unfurl into a proper card: the brand mark, the monthly savings we found, the number of findings.

That was two separate problems. Figuring them out took me somewhere I didn't expect, because neither one wanted a headless browser, but both needed a card. One of them even ate an afternoon because the fonts wouldn't cooperate. Here's the whole thing.

The dashboard is a single-page app, rendered entirely in the browser (ssr: false). That's fine for humans. It's not fine for the crawlers Slack, LinkedIn, and X send at your link, because those don't run JavaScript. They fetch the HTML, find an empty shell with no og: meta tags, and give up. You get the bare URL in the Slack thread.

So the crawler gets served something different. A CloudFront function reads the User-Agent on the viewer request: known bots get 302'd to a small server-rendered /embed page that carries the Open Graph tags and a meta refresh back to the real app. Humans get the SPA untouched. The crawler reads a fully-formed page with og:title, og:image, and friends; a human clicking the same link lands on the interactive dashboard.

This is user-agent-based dynamic rendering for social crawlers, not SEO cloaking. The /embed page carries the same title and numbers a human would see, and it meta refreshes straight to the real app, so nobody is handed content that contradicts what the link actually shows.

That's the easy half, structurally. Now comes the picture, which is where I had a choice to make.

The default path for "turn some text on a branded background into a PNG" is to render HTML in headless Chrome and screenshot it. It works. It also means shipping a browser alongside your service, keeping it patched, and paying its cold-start and memory cost on a code path that exists only to make links look nice. In a Rust backend that's a lot of weight to carry for a nice Slack unfurl.

I built the card as an SVG string and rasterized it in-process with resvg and tiny-skia, both pure Rust. The whole renderer is about ten lines:

static FONTDB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
let mut db = usvg::fontdb::Database::new();
db.load_font_data(SG_700.to_vec()); // include_bytes! at compile time
db.load_font_data(SG_500.to_vec());
db.load_font_data(INTER_600.to_vec());
Arc::new(db)
});

fn render_png(svg: &str) -> Result<Vec<u8>, ServiceError> {
let opt = usvg::Options { fontdb: FONTDB.clone(), ..Default::default() };
let tree = usvg::Tree::from_str(svg, &opt)?;
let mut pixmap = tiny_skia::Pixmap::new(CARD_W, CARD_H).ok_or(ServiceError::Render)?;
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
Ok(pixmap.encode_png()?)
}

A few things in there I'd do again:

  • The fonts are compiled into the binary with include_bytes!. No font files to ship, no system-font lookup that can differ between my laptop and the container. The binary just renders the same card everywhere, full stop.
  • LazyLock<Arc<Database>> parses the font data once per process. Each render clones the Arc (cheap) into its own usvg::Options, so building a card is string formatting plus a rasterize. That's the whole cost model.
  • The layout is single-column, left-aligned, on purpose. resvg doesn't expose a text-measurement API - you can't ask it how wide a string is before drawing it. Any centered element would need that. Committing to a left-aligned column means I never have to measure anything at all; the SVG is static positions with the numbers interpolated in.

The handler builds the SVG, calls render_png, and returns it with a one-hour Cache-Control. There's a CCA_OG_SAMPLE env var that dumps a real card to disk so I can eyeball it, and the test just asserts the output starts with the PNG magic bytes and has the right dimensions. That's deliberately not much - I'm not going to pixel-diff a card, but I do want to hear about the day the renderer stops producing a valid PNG.

Then I spent an afternoon wondering why everything was in the wrong font.

The cards looked structurally fine - right size, right colors, text in the right places

  • but the typeface was not what I wanted. Headings were supposed to be Space Grotesk. They were coming out in resvg's fallback face. Getting the font-family string "more correct" did not help, which took me a while to be okay with.

The SVG references the font the normal way:

<text font-family="Space Grotesk" font-weight="700" ...>
<text font-family="Space Grotesk" font-weight="500" ...>

The actual problem was upstream, in the font files themselves, which I only found when I finally opened one. I'd grabbed static TTFs from Fontsource, which ships each weight as a separate file - and, the part that got me, each file declares its own family name in the OpenType name table. The 700 file is not "Space Grotesk, weight 700." Depending on the face it calls itself "Space Grotesk" for one weight and "Space Grotesk Medium"/"Space Grotesk Light" for others. The filenames and the internal family names don't necessarily agree.

resvg (via usvg) matches fonts the CSS way: find a family named exactly "Space Grotesk," then pick the closest weight within that family. When the only family that literally exists in the database is "Space Grotesk Light," a request for font-family="Space Grotesk" matches nothing and silently falls back. No error, no warning - just the wrong font, which looked close enough that I kept debugging the SVG instead of the font.

The fix was normalizing the name table so both faces report the same family and distinguish themselves by weight class instead. A few lines of fonttools does it:

from fontTools.ttLib import TTFont
f = TTFont("space-grotesk-700.ttf")
for rec in f["name"].names:
if rec.nameID in (1, 16): # family name
rec.string = "Space Grotesk"
f["OS/2"].usWeightClass = 700 # keep the weight distinct
f.save("space-grotesk-700.ttf")

Now both faces belong to one "Space Grotesk" family with usWeightClass 500 and 700, and font-family="Space Grotesk" font-weight="700" resolves to exactly the face I meant.

One licensing note, since editing a font's name table can be a violation in some situations: the SIL Open Font License lets you modify and rename fonts freely unless the font declares a Reserved Font Name, in which case you're not allowed to keep that name on a modified copy. I checked - the faces I was using don't reserve one - so collapsing them to a single "Space Grotesk" family is fine. If yours does reserve a name, rename to something new instead. It's thirty seconds to confirm before you ship a modified .ttf, and I'd rather not find out the hard way.

Where it landed: a service that turns a shared scan into a branded PNG in-process, fonts baked into the binary, no browser anywhere in the pipeline. Share a scan link and it unfurls with a card showing the monthly savings and the finding count, generated the same way in every environment because there's nothing external left to disagree with itself.

If you're reaching for headless Chrome only to rasterize some text on a background, resvg plus tiny-skia is worth an hour of your time before you commit to it. And check your font's name table before you spend an afternoon wondering why "Space Grotesk" isn't Space Grotesk.


This is part of a series on building Cloud Cost Analyzer in Rust.