My blog, and another open-source project I'm involved in, both recently replaced Mermaid with pele, open-sourced by the Obsidian team. First, here's the same syntax rendered by each. On the left is the official Mermaid package, on the right is pele:
It's not just about looks. Here are the same 136 diagrams, compared against the official Mermaid package in the browser:
The full numbers and how I measured them are in the last section.
My blog uses Mermaid a lot. Flows, architectures, and sequences all rely on it. Right now the site has 44 posts, and across five locales that adds up to 136 diagrams. To make these diagrams look good without slowing pages down, I've gone through three renderers: first the official Mermaid package, then beautiful-mermaid in August, and last week, during the full site redesign, pele, open-sourced by the Obsidian team.
After switching to pele, I tested it against all 136 diagrams on the site: every one rendered successfully, the whole set took only about 75 milliseconds, roughly 9x faster than beautiful-mermaid, and the SVG output was 40% smaller. More importantly, it never touches the DOM, has no dependencies, and leaves all colors to CSS variables, which is exactly what I needed for "draw the SVG on the server, and load zero rendering code in the browser".
This post goes in order: what pele is, why I ended up switching to it, the benchmark numbers and the visual differences, how I wired it into TanStack Start (rendering on the server, switching themes with CSS only, writing colors back when downloading images), and finally a before-and-after comparison from another project that replaced the official Mermaid package in the browser. All the code is included, so if your blog uses Mermaid you should be able to use it directly as a reference.
What pele is#
pele is a Mermaid renderer open-sourced by the Obsidian team under the MIT license. The source lives at obsidianmd/pele on GitHub, and the npm publishers are Obsidian folks like kepano and lishid, publishing through GitHub Actions. It does one thing: turn Mermaid syntax into an SVG string.
Here's how I'd sum up its features:
- Synchronous, no dependencies:
render(text)returns{ svg }directly. No browser needed, no jsdom either. - Supports every diagram type built into Mermaid 12.1: each diagram's parser runs Mermaid's own test specs, and it's fuzz-tested against Mermaid's parser.
- Colors come only from CSS variables: the output SVG is colored with variables like
var(--pele-fg)andvar(--pele-surface), so switching themes doesn't require a re-render. - Safe: the output SVG has no scripts, no event handlers, no HTML, and it never executes any code from the diagram.
- You can load only the diagram types you need:
pele/coreplus a single diagram module, orpele/lazyto load on demand.
The layout and visual style are pele's own, so the same syntax will look different from what you see on GitHub or the Mermaid Live Editor. The comparison at the top makes that clear.
WARNING
The pele 1.0.0 on npm is a different project from 2020 (a LavaMoat experiment) and has nothing to do with this pele. Obsidian's pele only started publishing 1.x on October 6, 2026, and reached 1.12.0 within three weeks, with new releases almost every day. My site is currently pinned to 1.3.0.
Why I ended up at pele#
The official Mermaid package#
I started with the official Mermaid package plus ELK layout, rendering in the browser. It worked, but a few things kept bothering me:
- Too heavy: the renderer plus ELK made a lazy chunk of about 1.5 MB. If a post had even one diagram, readers had to download the whole thing.
- Diagrams blended into the background: the default node fill is a semi-transparent purple, which is nearly invisible on top of the site's dark starfield. Even I had to switch themes from the toolbar to read my own posts.
- Lots of guardrails to write: ELK serializes objects during layout, which ran into React fibers and caused circular JSON errors. I wrote a 35-line
JSON.stringifyguard just for that.
beautiful-mermaid#
In August I switched to beautiful-mermaid. It's synchronous, doesn't touch the DOM, and its SVG output is also colored with CSS variables, so I could move rendering entirely to the server: diagrams are drawn on the server, the browser only receives an SVG string, and client-side JS dropped from 20.18 MB to 18.80 MB.
Its problem was layout. Internally it's still ELK, so anything slightly complex ends up with crossing edges, and edge labels get packed into little boxes that look cramped. On top of that, it's not small either: 1.5 MB even after minification.
pele#
When I redesigned the site in October, I wanted to remove purple from the whole site, and the diagram block had to be redone anyway, so I switched to pele along the way. It keeps the properties of beautiful-mermaid that let me move rendering to the server (synchronous, no DOM, colors via CSS variables), but it's much smaller and the output is much cleaner.
Benchmark: against beautiful-mermaid#
I extracted all 136 Mermaid diagrams from the site's 44 posts across five locales (107 flowchart, 19 graph, 10 sequenceDiagram) and ran pele 1.3.0 and beautiful-mermaid 1.1.3 separately in Node on the same machine, three runs each.
| pele 1.3.0 | beautiful-mermaid 1.1.3 | |
|---|---|---|
| Succeeded | 136 / 136 | 136 / 136 |
| Full run | 72–91 ms | 657–693 ms |
| Median per diagram | 0.4 ms | 3.7 ms |
| p95 per diagram | 1.4 ms | 8.2–11.3 ms |
| Slowest diagram | 7–10 ms | 94–102 ms |
| Average SVG size | 4.8 KB | 7.7 KB |
For size, I used esbuild to build a minified browser bundle of each:
| Bundle contents | Minified | gzip |
|---|---|---|
| pele, all diagram types | 626 KB | 231 KB |
| pele, flowchart only | 112 KB | 43 KB |
| beautiful-mermaid | 1,522 KB | 457 KB |
My site renders on the server, so none of this ends up in the reader's browser. If you plan to use it on the client, registering only the diagram types you need makes a big difference with pele: flowchart alone is 43 KB after gzip.
How the output differs#
Below is the same diagram from the CrowdSec post. The old site is frozen from before the renderer switch, so it's still drawn by beautiful-mermaid:
After switching to pele:
It's the same left-to-right flowchart, but pele lays it out in a straight line, the two edges going back and forth run neatly in parallel, and the labels sit right on the lines with no crossings at all. This diagram originally had nodes I marked green and yellow with classDef. You won't see those two colors in the new version. That's something I handle separately on the site side, which I'll cover later.
How I wired it into the site#
My site uses TanStack Start. The overall approach: the server renders every Mermaid block in a post into SVG and sends it to the browser along with the post data, and the browser only puts the SVG into the page and handles zooming and the toolbar.
Rendering on the server#
Rendering is a single line, and colors never go into the renderer:
// src/lib/mdx/mermaidSvg.ts
import { render } from 'pele';
export function renderMermaidSvg(body: string): string {
return render(body).svg;
}Then I wrap it in a server function that renders every diagram in a post into a "diagram content hash → SVG" lookup table:
// src/lib/mdx/mermaidRender.ts
export const renderMermaidSvgs = createServerFn({ method: 'POST' })
.validator((content: string) => content)
.handler(async ({ data: content }) => {
const fences = extractMermaidFences(content);
if (fences.length === 0) return {};
// Must be a dynamic import inside the handler; no static import at module top level
const { renderMermaidSvg } = await import('./mermaidSvg');
const out: Record<string, string> = {};
for (const body of fences) {
const key = mermaidKey(body);
if (out[key]) continue;
try {
out[key] = renderMermaidSvg(body);
} catch {
// One diagram with broken syntax shouldn't fail the whole post
}
}
return out;
});This lookup table is fetched when the post data loads and sent to the browser along with the query, and the diagram component just looks things up in it. Since the SVG is already drawn on the server, it also shows up in the SSR HTML, so readers see the diagrams as soon as the page opens, without waiting for JS.
There's one pitfall you have to avoid: the renderer can only be imported dynamically. On my first attempt, the SSR side used a static import and the browser side used a dynamic import. With the same module referenced both ways, rollup couldn't split it out and merged it straight into the main chunk, and the post page's route chunk blew up from 127 KB to 1,613 KB. After changing everything to a dynamic import inside the server function, the renderer's code is nowhere to be found on the client.
Switching themes only swaps CSS variables#
The SVG pele outputs is colored with --pele-* variables, and their values come from a class on the outer element. My site keeps just two themes: Night (the site's colors) and Paper (light, handy for downloading as an image to paste elsewhere):
.mm-theme-ink {
--pele-bg: transparent;
--pele-fg: var(--paper);
--pele-muted: var(--paper-3);
--pele-line: var(--paper-2);
--pele-surface: rgba(var(--paper-rgb), 0.05);
--pele-surface-alt: rgba(var(--paper-rgb), 0.1);
--pele-border: var(--paper-3);
--pele-accent: var(--amber);
--pele-font: var(--cjk-font);
--pele-font-mono: var(--mono-font);
--pele-radius: 2px;
}When a reader switches to Paper in the toolbar, all that happens is mm-theme-ink gets swapped for mm-theme-light. The diagram doesn't need a re-render, so the browser doesn't need the renderer either.
Downloading images means writing the colors back#
This approach has a side effect: the SVG itself has no colors; they come from the outer CSS. Once a reader clicks "Download SVG" and the file leaves the page, the variables have no values, and it falls back to pele's default black text on white. So before the download, I write the currently computed variable values into the SVG itself:
const PELE_VARS = ['--pele-bg', '--pele-fg', '--pele-muted', '--pele-line', '--pele-surface',
'--pele-surface-alt', '--pele-border', '--pele-accent', '--pele-font', '--pele-font-mono', '--pele-radius'];
function standaloneSvg(svg: SVGSVGElement): SVGSVGElement {
const clone = svg.cloneNode(true) as SVGSVGElement;
const cs = getComputedStyle(svg);
for (const name of PELE_VARS) {
const value = cs.getPropertyValue(name).trim();
if (value) clone.style.setProperty(name, value);
}
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
return clone;
}Downloading a PNG also starts by drawing this SVG onto a canvas, with the background following the theme: the site's black for Night, white for Paper.
The colors I marked in my diagrams#
In older posts I often used classDef to color nodes: light green for "holds", light red for "ruled out", pale yellow for "key point". Eleven posts use it. After the redesign the whole site only keeps black, white, and one amber, so these colors would look out of place, but they're part of the content and I can't just drop them.
So when rendering on the server, I added one more step: read the color I originally gave each node, judge from its hue what it means in the diagram, map it to one of four roles, then strip the original color and attach the matching class instead, leaving it to the site's CSS to decide how to draw it. In other words, a color no longer directly decides what a node looks like; it first gets translated into "what this node means".
| Original color | Role | How it's drawn |
|---|---|---|
| Green | Holds | Brightest border, very faint fill |
| Yellow, orange | Key point | Same as above, plus an amber dot in the top-left corner; only one per diagram |
| Blue, purple | Secondary | Border and text one step dimmer |
| Red | Ruled out | Dotted border, no fill, text another step dimmer |
Grayscale and anything it doesn't recognize are drawn with the defaults. In the diagram earlier, that's where the amber dot in the top-left corner of Jarvis bot comes from. The posts themselves don't need a single word changed, and these styles are written in when downloading images too.
Head to head with official Mermaid: 20x faster in the browser#
I also maintain an open-source project with a friend, tessera. It's a self-hosted file and note sharing service where shared items clean themselves up when their time runs out, and it's still in development. Notes can contain Mermaid, which originally used the official Mermaid package. After finishing the blog, I asked my collaborator to switch tessera to pele too, and PR #148 was merged on October 28.
tessera's situation is the exact opposite of the blog's: users type notes and preview them live in the browser, so diagrams can only be drawn on the client, and the content is pasted in by other people, so it can't be trusted. Every character changed means the diagram gets re-rendered; opening a note someone shared means the browser first has to download the renderer. So the renderer's size and speed are things you directly feel here.
The results#
The PR didn't measure size, so I measured it myself: following tessera's code before and after the switch (same versions, same settings), I bundled each into an on-demand module with esbuild and rendered the site's 136 diagrams in sequence in Edge, three runs each.
| Mermaid 11.17.2 (before) | pele 1.12.0 (after) | |
|---|---|---|
| JS downloaded for the first diagram | 821 KB (gzip 224 KB) | 160 KB (gzip 67 KB) |
| First diagram rendered (including load) | 110–132 ms | 29–35 ms |
| All 136 rendered | 4.1–4.5 s | 0.19–0.24 s |
| Median per diagram | 28 ms | 1.1–1.4 ms |
| Average SVG size | 27.5 KB | 4.8 KB |
| Switching light/dark mode | Re-renders every diagram | No re-render |
Opening a note with a diagram now downloads about a third of the renderer it used to; while typing and previewing, each re-render goes from nearly 30 milliseconds to just over 1 millisecond, roughly 20x faster. These times were measured with local loading and no throttling, so they mainly reflect the cost of parsing and executing JS; over a real network, the extra 150-plus KB Mermaid has to download comes on top of that. All 136 diagrams rendered successfully on both sides, and none of them broke because of the switch.
How to wire it in#
This uses pele the other way, loading on demand in the browser:
// Only load the renderer when there's a diagram to draw, keeping it out of the main bundle
const { renderAsync } = await import('pele/lazy');
// Pasted content is untrusted: the output carries no links and makes no image requests
const rendered = await renderAsync(trimmed, {
images: false,
links: false,
});Colors are left to CSS variables here too. tessera already has a set of design tokens, so I bind --pele-* to them directly on :root. When switching light/dark mode or changing the accent color, the diagrams follow along without a re-render:
:root {
--pele-bg: var(--card);
--pele-fg: var(--foreground);
--pele-surface: var(--muted);
--pele-border: var(--border);
--pele-accent: var(--primary);
/* Map the remaining variables to the existing tokens the same way */
}The code that got deleted#
Besides being faster, it's much easier to maintain. The rendering hook lost 66 lines and only gained 12 back, and almost everything deleted was there to work around Mermaid:
- Every render had to generate a unique id.
- When Mermaid fails to render, it leaves the container it uses for measuring on
body, and they pile up (mermaid-js/mermaid#4730), so you have to clean them up yourself. - The SVG Mermaid outputs has its width set to 100%, with the real size hidden in an inline style, so it had to be rewritten into
widthandheightwith a regular expression before the zoom window could measure it. pele's SVG carries its own width and height. - Changing the theme meant re-rendering the whole diagram.
The lockfile also lost the 80-plus packages Mermaid brought in, like d3, cytoscape, KaTeX, and DOMPurify, and the only addition is pele, which has no dependencies of its own. The same renderer is used on the server by the blog and in the browser by tessera, and both integrations are simple.
Things to watch out for#
- Pin the version: pele is updating very fast right now, with over ten releases in three weeks. I'd recommend pinning the version, reading the changes before upgrading, and running your own diagrams through it again.
- It doesn't look like Mermaid: pele only promises syntax compatibility; the layout and visuals are its own. If your readers will compare diagrams against how GitHub renders them, be prepared.
- Error messages quote the source: when
render()hits a syntax error or an unsupported type, it throws aPeleErrorwhose message includes the diagram's source text. Treat it as plain text when displaying it, and don't put it straight into HTML. - In the browser you can use
mount(): it reads fonts from CSS and adjusts the layout to the container width. My site renders on the server, so I don't use it.
Since the switch, all 136 diagrams on the site work fine, and post pages no longer carry any renderer; over on tessera, the renderer download is down to a third of what it was. I already use Obsidian for my notes every day, and I didn't expect the diagrams on my blog to end up using something made by the same team.
- pele website and docspele.run
- pele theme variablespele.run
- pele source codeGitHub
- The PR switching tessera to peleLimatura/tessera#148
- Mermaidmermaid.js.org