ehsan.blog
~/blog/how-to-lazy-load-mermaid-only-on-pages-with-diagrams — zsh
cat how-to-lazy-load-mermaid-only-on-pages-with-diagrams.md

How to lazy-load the Mermaid runtime only on pages that have a diagram

·4 min read

Mermaid renders lovely diagrams, but the library is big. On a personal blog, most posts have no diagrams at all — so shipping the mermaid runtime to every page would mean the vast majority of my visitors download hundreds of kilobytes of JavaScript they’ll never use. This post shows the small guard I use so mermaid loads only on pages that actually contain a diagram.

The problem with importing at the top

The naive approach is a static import at the top of your script:

ts
import mermaid from "mermaid"

The trouble is that a static import is resolved when the module loads. Your bundler sees it, pulls mermaid into the bundle for that script, and every page that runs the script downloads mermaid — diagrams or not. There’s no way for the browser to skip it, because “do I need this?” is a runtime question and the static import was decided at build time.

I want the opposite: decide at runtime, in the browser, and only pay the cost when the answer is yes.

Two tools: a dynamic import and a DOM check

Two things make this work.

First, a dynamic importimport("mermaid") written as a function call, not a statement. It returns a promise and only fetches the module when that line actually runs. Bundlers split it into its own chunk that’s requested on demand. So if the line never runs, the chunk never downloads.

Second, a cheap DOM check to decide whether the line should run. As I covered in rendering Mermaid diagrams in Astro MDX, every diagram in a rendered post ends up as a <pre class="mermaid"> element in the HTML. So “does this page have a diagram?” is just “is there a pre.mermaid on the page?” — a single querySelectorAll.

Put them together and the rule is: query the DOM first, bail out if there’s nothing, and only reach the dynamic import when a diagram exists.

The real guard

Here’s the top of my client script, straight from the component that enriches rendered posts:

ts
// blog/src/components/PostEnhancements.astro
async function renderMermaid() {
  const nodes = Array.from(
    document.querySelectorAll<HTMLElement>("pre.mermaid:not([data-processed])"),
  )
  if (nodes.length === 0) return

  const { default: mermaid } = await import("mermaid")

  mermaid.initialize({
    startOnLoad: false,
    securityLevel: "strict",
    theme: "base",
    // ...theme variables
  })

  for (const [i, node] of nodes.entries()) {
    const source = node.textContent ?? ""
    node.setAttribute("data-processed", "")
    const { svg } = await mermaid.render(`mermaid-svg-${i}-${Date.now()}`, source)
    // ...swap the <pre> out for the rendered SVG
  }
}

The load-bearing three lines are the first ones:

ts
const nodes = Array.from(
  document.querySelectorAll<HTMLElement>("pre.mermaid:not([data-processed])"),
)
if (nodes.length === 0) return

const { default: mermaid } = await import("mermaid")

Read them in order. We collect the unprocessed diagram elements. If there are none, we return immediately — and crucially, that return happens before the await import("mermaid") line. On a diagram-free page, execution never reaches the import, so the mermaid chunk is never requested. The browser downloads nothing extra.

Only when nodes.length is greater than zero do we hit await import("mermaid"), which triggers the on-demand download of the mermaid chunk, resolves to the module, and lets us initialize and render.

What actually loads, when

flowchart TD
  A[Post loads<br/>PostEnhancements script runs] --> B{pre.mermaid<br/>on the page?}
  B -- No --> C[return early<br/>mermaid never downloaded]
  B -- Yes --> D[await import mermaid<br/>fetch chunk on demand]
  D --> E[initialize + render SVG]

Once the chunk is loaded, mermaid.initialize() is where I feed in the diagram colors — a step with its own wrinkle when your palette is OKLCH, which I cover in theming Mermaid when your palette uses OKLCH.

The :not([data-processed]) part is a small bonus: after rendering each node I set data-processed, so if renderMermaid() ever runs twice (say, after a client navigation) it won’t re-process diagrams it already handled — and on a page whose diagrams are all done, the selector returns nothing and we bail early again.

Why the ordering is the whole trick

It’s worth being explicit, because it’s easy to get wrong. If you flip the two — import first, then check — you’ve lost the win entirely:

ts
// ❌ don't do this
const { default: mermaid } = await import("mermaid") // downloads on EVERY page
const nodes = document.querySelectorAll("pre.mermaid")
if (nodes.length === 0) return

Here the import runs on every page before the check has a say, so every visitor downloads mermaid whether or not the post has a diagram. The early return has to come first. Guard, then import — never the other way around.

Takeaways

  • A static import mermaid bundles the library into every page’s script; you can’t opt out at runtime.
  • A dynamic import("mermaid") only downloads the chunk when that line actually executes.
  • Check the DOM for pre.mermaid and return early when there are none — before the import — so diagram-free pages ship zero mermaid JS.
  • Order is everything: guard first, dynamic import second.
  • :not([data-processed]) keeps re-runs cheap and idempotent.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.