A handful of posts on this site have Mermaid diagrams in them. Every one of those pages was shipping around 196KB of JavaScript to draw a picture that never changes, and worse, the drawing was the single biggest layout shift on the page. I fixed both, and the interesting part was keeping the diagrams theme-aware without any client JavaScript at all.
A post with a diagram now loads the exact same JavaScript as a post with none. The diagram is in the HTML the server sends, at its final size, and it still switches between light and dark with the rest of the page.
The two problems
The old setup rendered each diagram in the reader's browser. A <MermaidClient> component dynamically imported Mermaid, waited for fonts, and drew the SVG on mount. That gave two problems for the price of one:
- Weight. Mermaid is big. The diagram page pulled 28 scripts and about 370KB, where an otherwise identical page with no diagram pulled 14 scripts and about 174KB. So one flowchart cost roughly 196KB and 14 extra requests.
- Movement. While all that loaded, the reader saw a small "Rendering diagram…" placeholder about 86px tall. The diagram that replaced it is 855px tall on a phone and 1829px at desktop width. That's 800 to 1700px of content getting shoved down the page, late, and it started above the fold. That is a nasty Cumulative Layout Shift.
Both problems have the same root cause: the browser was doing work that never changes between page loads. The diagram source is fixed and the output is deterministic, so there's no reason to compute it more than once, let alone once per visitor.
Move the drawing to build time
The fix is to draw each diagram once, when the site is built, and commit the finished SVG. A small script walks content/, renders every ```mermaid block with headless Chromium (via Playwright, which was already a dev dependency), and writes the SVG to content/diagrams/, named by a hash of the chart source. The blog renderer then inlines that SVG on the server.
Here's the pipeline, drawn, naturally, by the pipeline itself:
Because the filename is a content hash, editing a chart orphans its old SVG instead of silently reusing it, and an unchanged chart is a no-op. And because there's no longer a client renderer to paper over mistakes, a chart with no committed SVG fails the build rather than leaving a hole in the post. It's generated locally and committed, exactly like the LaTeX CV on this site, so Vercel never has to download a browser.
Reserving the space
Inlining the SVG on the server is half the fix. The other half is making sure it takes its final height on the first layout pass. Mermaid sizes its root in absolute pixels, which a stylesheet then overrides to width:100%, but a stylesheet can arrive late, and a box that's one width for a frame and another the next is the exact shift I was trying to kill. So the script strips the fixed dimensions and lets the viewBox carry the aspect ratio:
<!-- before: a fixed box that a late stylesheet has to correct -->
<svg width="447.2" height="1258.1" viewBox="-4 -4 447.2 1258.1">
<!-- after: sizes itself from the viewBox, no correction needed -->
<svg viewBox="-4 -4 447.2 1258.1"
preserveAspectRatio="xMidYMin meet"
style="width:100%;height:auto">
The intrinsic ratio is on the element itself, so the first layout is already the final one. Nothing to shift into.
The catch: dark and light mode
This is the part I actually enjoyed. The site follows your system's colour scheme, with no toggle, just prefers-color-scheme, and the diagrams have to follow it too. That was easy when a component re-ran mermaid.render() on every theme change. Pre-rendering to a single static file makes it look impossible: how does one committed SVG show two different colour schemes?
The answer starts with an observation. I render each chart twice, once in Mermaid's light theme and once in its dark theme, and compared the two outputs. They're byte-for-byte identical except for the colours. Same node positions, same paths, same text. Only the fills, strokes and the embedded stylesheet differ. The script actually asserts this on every run and fails if the two renders ever disagree on geometry, so the assumption can't quietly rot.
That means I can keep one copy of the drawing and give it two sets of colours. Mermaid emits its theme as a <style> block scoped to the SVG's id, so I take the light stylesheet as the base and drop the dark one into a media query:
<svg id="mermaid-efce…" viewBox="…">
<style>
/* Mermaid's light theme - the default */
#mermaid-efce… .node rect { fill: #e7e1d8; stroke: #d8d0c4; }
#mermaid-efce… .marker { fill: #b7aa9a; }
/* Mermaid's dark theme - only when the reader asks for it */
@media (prefers-color-scheme: dark) {
#mermaid-efce… .node rect { fill: #111827; stroke: #374151; }
#mermaid-efce… .marker { fill: #6b7280; }
}
</style>
…one copy of the geometry…
</svg>
The browser paints the base rules, and if the reader is in dark mode the media query overrides them. There's no JavaScript involved, no matchMedia listener and no re-render, just the same mechanism the rest of the page already uses. Switching your OS theme with the page open is now an instant repaint instead of a full diagram redraw.
The one thing CSS couldn't reach
Almost everything is styleable through that stylesheet. Almost. When you colour a specific node in Mermaid source:
style G fill:#e8f5e8
Mermaid writes that as an inline style="fill:#e8f5e8 !important" on the shape, and inline !important beats anything a stylesheet can say. Those custom fills were the one part of the drawing a media query couldn't repaint. So the merge lifts them into CSS custom properties: the inline fill becomes fill:var(--mmd-c0), and each theme's stylesheet declares a different value for --mmd-c0.
/* light */ #mermaid-efce… { --mmd-c0: #e8f5e8; }
/* dark */ #mermaid-efce… { --mmd-c0: #064e3b; }
<polygon style="fill:var(--mmd-c0) !important" … />
The !important still wins, but now it resolves to a variable that the theme controls. The green "success" node in the diagram above, and the pink and blue ones, are exactly this. If you're reading in light mode they're pastel; flip to dark and they deepen. Same file, same bytes, both themes.
A quiet bonus: this fixed a bug. The old client renderer baked whichever theme was active when it ran into the SVG's markers, and the site's CSS never overrode arrowhead fills, so an arrowhead could end up frozen at the wrong theme's colour. Driving everything through the media query means the arrows follow the theme now too.
What it cost, what it saved
- JavaScript: the diagram page now loads the same 12 chunks as a page with no diagram. Mermaid is gone from the client entirely.
- Layout shift: the SVG is in the server-sent HTML at its final size. I checked with a
MutationObserverand there were zero mutations to the diagram after load. There's nothing left that can shift. - The trade: the SVG is inlined, so it lives in the HTML and the navigation payload. That's about 11KB gzipped per diagram page, against roughly 196KB of JavaScript removed. Lopsided in the right direction.
- Responsiveness: unchanged. The SVG still scales to fit its column. I only moved who draws it, not how it fits.
Lessons
- If the output never changes, don't compute it in the browser. The diagram source is fixed and the render is deterministic, which is the definition of build-time work.
- Reserve space from the element, not a stylesheet. A
viewBoxplusheight:automeans the first layout is the final layout, with or without CSS. - Two renders, one drawing. When outputs differ only in colour, keep the geometry once and let a media query and a few custom properties carry the theme. Then assert they really do only differ in colour, so a future Mermaid upgrade can't break it silently.
- Fail loudly. Deleting the fallback renderer meant a missing diagram had to break the build. A visible failure at build time beats an invisible hole in production.
Related reading
- HTML docs for AI, on why the content here is plain HTML in the first place.
- Automating my GitHub avatar sync, another "let the build do the boring part" pipeline, and home to one of these diagrams.