José David Baena

On This Page

On this page

Simple CSS Fixed What 308 Lines of JavaScript Couldn't

Banner.jpeg
Published on
/22 mins read

📚 Series: Mermaid Diagrams in Next.js

Previously in this series: Part 1 covered failed build-time SVG generation with Puppeteer (45s builds). Part 2 detailed building 706 lines of pan/zoom controls with momentum physics, only to discover SVG pixelation issues and realize users didn't need interactive features.

Architecture 3: Simple Client-Side Solution

📦 Mermaid Version Note: This tutorial uses Mermaid v10.9.1. The code examples and API calls shown are compatible with v10.x.

For Mermaid v11.x users: Breaking API change in v11.0.0+

  • Replace mermaid.initialize() with await mermaid.run()
  • See the official migration guide for full details
  • Bundle size increased from ~373 KB (v10.x) to ~480 KB (v11.x)

The Simplification (Choosing Pragmatism Over Perfection)

After implementing 706 lines of interactive controls, debugging pixelation issues, and spending days on momentum physics, I finally stopped and asked the most important question:

"Do readers actually need to pan and zoom diagrams?"

Looking at my blog content:

  • 90% of diagrams fit comfortably on screen at 100% zoom
  • The few large diagrams were readable with browser native zoom (Cmd/Ctrl +/-)
  • Nobody had ever requested interactive controls
  • Readers on mobile had pinch-to-zoom built-in

The honest answer: No. This was a solution looking for a problem.

The revelation: I had been optimizing for the engineering challenge (building smooth pan/zoom), not for user value (can readers understand my diagrams?).

This is a common trap: building features because they're technically interesting, not because they solve real problems. As engineers, we love complex solutions. But users love working solutions.

What to Keep and Delete

Keep:

  • ✅ Client-side Mermaid rendering (works reliably)
  • ✅ Code-splitting with dynamic imports (performance win)
  • ✅ Theme support (respects user preference)
  • ✅ Basic Mermaid component (simple, maintainable)

Delete:

  • ❌ Pan/zoom hook (308 lines → 0)
  • ❌ Diagram controls UI (322 lines → 0)
  • ❌ Wrapper components (76 lines → 0)
  • ❌ Complex event handling (click, drag, wheel, keyboard)
  • ❌ Physics simulation (momentum, damping)
  • ❌ SVG viewBox manipulation plans

Total deletion: 706 lines of code

What I needed to fix: The pixelation issue (which was caused by my pan/zoom implementation).

The solution: Simple CSS rendering hints.

The Final Implementation (From 706 Lines to 31 Lines)

Step 1: Simplify the Remark Plugin

The remark plugin transforms markdown code blocks into React components. My interactive version was 56 lines. The simple version:

utils/remark-mermaid-mdx.ts
import { visit } from 'unist-util-visit'
 
/**
 * Remark plugin to transform Mermaid code blocks into Mermaid components
 *
 * Transforms:
 *   ```mermaid
 *   graph TD
 *     A --> B
 *   ```
 *
 * Into:
 *   <Mermaid chart="graph TD\n  A --> B" />
 */
export function remarkMermaidMdx() {
  return (tree: any) => {
    // Walk through all nodes in the markdown AST
    visit(tree, 'code', (node: any, index: number, parent: any) => {
      // Only process mermaid code blocks
      if (node.lang !== 'mermaid') return
      
      // Replace code block with MDX component
      parent.children[index] = {
        type: 'mdxJsxFlowElement',  // This is an MDX JSX element
        name: 'Mermaid',             // Component name
        attributes: [
          {
            type: 'mdxJsxAttribute',
            name: 'chart',           // Prop name
            value: node.value,       // The diagram code
          },
        ],
        children: [],
      }
    })
  }
}

What changed:

  • Before: Generated <MermaidInteractive> with complex AST for props, controls, wrappers
  • After: Generates simple <Mermaid chart="..." />
  • Reduction: 56 lines → 28 lines (50% smaller)

This is pure markdown transformation. No logic, no state, no complexity. Just: "When you see mermaid code, swap it for a Mermaid component."

Step 2: Fix SVG Rendering Quality with CSS

Remember the pixelation problem? The one I was going to fix with complex SVG viewBox manipulation? Turns out CSS has hints for this:

css/tailwind.css
/* Tell browsers to prioritize quality when rendering SVGs */
.mermaid-container svg {
  image-rendering: optimizeQuality;      /* Prefer quality over speed */
  shape-rendering: geometricPrecision;   /* Render shapes precisely */
}

What these properties do:

  • image-rendering: optimizeQuality - Tells browser: "I don't care if rendering is slower, make it look good"
  • shape-rendering: geometricPrecision - Tells browser: "Render curves and lines accurately, not optimized"

Note: The text-rendering property doesn't affect SVG text rendering in most browsers. Only these two properties meaningfully improve SVG diagram quality.

The result: Crisp, sharp SVG rendering at all zoom levels. Even when users zoom to 300% with browser zoom, vectors stay sharp.

Two CSS properties fixed what I thought needed 308 lines of JavaScript.

Teaching moment: Understanding how browsers work

Browsers have rendering hints for different scenarios:

  • optimizeSpeed - Fast rendering, lower quality (for animations, games)
  • optimizeQuality - Slow rendering, high quality (for diagrams, illustrations)
  • auto - Browser decides

By default, browsers optimize for speed (most web content is photos, not diagrams). By explicitly requesting quality, we get sharp vector rendering.

This is called working with the platform—using built-in browser features instead of reimplementing them in JavaScript.

Step 3: Delete All the Pan/Zoom Code

This felt both liberating and painful. I had spent days building this:

# Files deleted
$ rm hooks/use-pan-zoom.ts                          # 308 lines
$ rm components/mdx/diagram-controls.tsx            # 322 lines
$ rm components/mdx/mermaid-with-controls.tsx       # 38 lines
$ rm components/mdx/mermaid-interactive-client.tsx  # 38 lines
 
# Total: 706 lines deleted

What I lost:

  • ❌ Smooth momentum scrolling
  • ❌ Zoom toward cursor
  • ❌ Keyboard shortcuts (+/-/R/F)
  • ❌ Hover-only UI controls
  • ❌ Touch gesture support
  • ❌ Custom physics simulation
  • ❌ Hundreds of lines of test surface area

What I gained:

  • ✅ Simpler codebase
  • ✅ No event handling bugs
  • ✅ No cross-browser testing
  • ✅ No maintenance burden
  • ✅ Faster page loads (30 KB smaller bundle)
  • ✅ Browser native zoom (free feature!)

The trade-off: Users can't pan diagrams with mouse drag. They use browser zoom (Cmd/Ctrl +/-) or pinch-to-zoom on mobile.

User impact: Zero complaints. Because the existing solution was already fine.

Step 4: Update Component Registry

Remove the interactive component from MDX exports:

components/mdx/index.tsx
// Before (Architecture 2)
import { MermaidInteractive } from './mermaid-interactive-client'
 
export const MDX_COMPONENTS = {
  pre: Pre,
  code: Code,
  MermaidInteractive,  // Complex wrapper with controls
  // ... other components
}
 
// After (Architecture 3)
// No import needed - Mermaid is auto-imported by contentlayer
// The remark plugin generates <Mermaid>, which MDX resolves automatically
 
export const MDX_COMPONENTS = {
  pre: Pre,
  code: Code,
  // ... other components
}

How this works:

The remark plugin generates <Mermaid chart="..." /> in the MDX. Next.js's MDX loader automatically resolves components from:

  1. Imported components in the MDX file
  2. Global MDX component context
  3. Local component scope

Since we already have Mermaid exported from components/mdx/mermaid.tsx, it "just works."

No wiring needed. No registration. No imports. Just simple, automatic resolution.

The Complete Simple Architecture

Let me show you the entire data flow:

Loading diagram...

Key characteristics:

  • Simple: Linear data flow, no branches
  • Fast: Code-split, conditional loading
  • Maintainable: 28 lines of transformation logic
  • Reliable: No custom event handling, physics, or state
  • Accessible: Works with browser zoom, screen readers

Final Code Count

Let me compare all three architectures:

ArchitectureLines of CodeComplexityResult
Architecture 1 (Build-time)~150 lines + Puppeteer🔴 High❌ Couldn't implement
Architecture 2 (Interactive)706 lines🔴 Very High⚠️ Worked but over-engineered
Architecture 3 (Simple)31 lines🟢 Low✅ Shipped to production

The math:

  • Architecture 2 → Architecture 3: Deleted 95.6% of code
  • Bundle size reduction: 30 KB (7.7%)
  • Maintenance burden: Effectively eliminated
  • User complaints: Zero

The lesson: More code doesn't mean better software. Sometimes 31 lines beats 706 lines in every dimension that matters.

Architecture Comparison

Loading diagram...

Architecture 3 Verdict

AspectScoreNotes
Performance🟢 GreatCode-split, ~373 KB only when needed
Bundle Size🟢 Great706 lines removed (-30 KB)
User Experience🟢 GreatNative browser zoom works fine
Code Complexity🟢 Great28 lines of remark plugin
Maintenance🟢 GreatSimple, clear, maintainable

Decision: ✅ Shipped to production.

Performance Comparison

Bundle Size Analysis

ArchitectureJavaScriptCSSTotalNotes
Build-Time0 KB0 KB0 KB❌ Couldn't implement
Interactive403 KB2 KB405 KB373 KB Mermaid + 30 KB controls
Simple373 KB1 KB374 KBJust Mermaid.js, code-split

Savings: 31 KB (7.7%) from removing pan/zoom code

Load Time Impact

Measured with Chrome DevTools on cable connection (5 Mbps):

ArchitectureFCPLCPTTINotes
Interactive1.2s2.8s3.1sLoads controls + Mermaid
Simple1.2s2.6s2.9sLoads just Mermaid

Improvement: 200ms faster TTI (6.5% faster)

Code Maintainability

MetricInteractiveSimpleChange
Total Lines70628-96%
Files62-67%
DependenciesReact, hooks, refsJust ReactSimpler
Test ComplexityHigh (event handling, physics)Low (just rendering)Much easier

Lessons Learned

Question Your Assumptions

I assumed readers would benefit from interactive diagrams. In reality:

  • Most diagrams fit on screen
  • Browser native zoom (Cmd +/-) works fine
  • Nobody requested interactive controls

Learning: Build for actual user needs, not imagined ones.

Complexity Has Costs

The interactive implementation looked impressive:

  • ✨ Smooth momentum scrolling
  • 🎯 Zoom toward cursor
  • ⌨️ Keyboard shortcuts
  • 📱 Touch gesture support

But the costs were real:

  • 706 lines of code to maintain
  • Custom event handling logic
  • Physics simulation (momentum damping)
  • Cross-browser testing burden
  • SVG pixelation debugging

Learning: Every feature has a carrying cost. Weigh it carefully.

Simple Solutions Often Win

The final solution:

  • 2 CSS properties for crisp rendering
  • 28 lines of remark plugin
  • Zero custom event handling
  • Native browser zoom support

It's boring. It's simple. It works perfectly.

Learning: Boring technology is good technology.

Prototype Quickly, Decide Slowly

I didn't regret exploring build-time rendering or interactive controls. The exploration taught me:

  • Build-time rendering limitations (DOM dependencies)
  • SVG rendering quality issues (CSS transforms)
  • Component architecture patterns (remark plugins)
  • User needs vs. developer wants

Learning: Prototypes are for learning, not shipping.

Delete Code Confidently

Deleting 706 lines felt wrong at first. That was working code! But keeping it would have been worse:

  • Ongoing maintenance burden
  • Future developers learning unnecessary complexity
  • Bundle size overhead for every visitor

Learning: The best code is code you don't have to maintain.

Implementation Guide

Want to add Mermaid diagrams to your blog? Here's the simple solution:

Step 1: Install Dependencies

npm install mermaid

Step 2: Create Mermaid Component

components/mermaid.tsx
'use client'
 
import mermaid from 'mermaid'
import { useTheme } from 'next-themes'
import { useEffect, useRef } from 'react'
 
// Initialize with theme
mermaid.initialize({
  startOnLoad: false,
  theme: 'default',
})
 
export function Mermaid({ chart }: { chart: string }) {
  const ref = useRef<HTMLDivElement>(null)
  const { theme } = useTheme()
  
  useEffect(() => {
    if (!ref.current) return
    
    // Clear previous
    ref.current.innerHTML = ''
    
    // Update theme
    mermaid.initialize({
      theme: theme === 'dark' ? 'dark' : 'default',
    })
    
    // Render
    const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`
    mermaid.render(id, chart)
      .then(({ svg }) => {
        if (ref.current) {
          ref.current.innerHTML = svg
        }
      })
      .catch(console.error)
  }, [chart, theme])
  
  return <div ref={ref} className="mermaid-container my-8" />
}

Step 3: Code-Split for Performance

components/mermaid-client.tsx
'use client'
 
import dynamic from 'next/dynamic'
 
export const MermaidClient = dynamic(
  () => import('./mermaid').then((mod) => ({ default: mod.Mermaid })),
  {
    ssr: false,
    loading: () => <div>Loading diagram...</div>,
  }
)

Step 4: Add Remark Plugin

utils/remark-mermaid-mdx.ts
import { visit } from 'unist-util-visit'
 
export function remarkMermaidMdx() {
  return (tree: any) => {
    visit(tree, 'code', (node: any, index: number, parent: any) => {
      if (node.lang !== 'mermaid') return
      
      parent.children[index] = {
        type: 'mdxJsxFlowElement',
        name: 'Mermaid',
        attributes: [
          {
            type: 'mdxJsxAttribute',
            name: 'chart',
            value: node.value,
          },
        ],
        children: [],
      }
    })
  }
}

Step 5: Configure Contentlayer/MDX

contentlayer.config.ts
import { remarkMermaidMdx } from './utils/remark-mermaid-mdx'
 
export default makeSource({
  mdx: {
    remarkPlugins: [
      remarkMermaidMdx,  // Add this BEFORE rehypePrettyCode
      // ... other plugins
    ],
  },
})

Step 6: Add CSS Quality Hints

styles/global.css
.mermaid-container svg {
  image-rendering: optimizeQuality;
  shape-rendering: geometricPrecision;
}

Step 7: Use in Content

content/my-post.mdx
Here's a diagram:
 
\`\`\`mermaid
graph TD
    A[Start] --> B{Decision}
    B -->|Yes| C[Do Something]
    B -->|No| D[Do Something Else]
    C --> E[End]
    D --> E
\`\`\`

Total implementation: ~100 lines of code for full Mermaid support.


Handling Mermaid Syntax Errors

Mermaid diagrams can fail silently if syntax is wrong. Here's how to debug and handle errors gracefully:

Common Errors

Invalid Arrow Syntax:

graph TD
    A[Start] -> B[End]  <!-- Wrong! Should be --> -->

Error Message:

Error rendering diagram: Syntax error in graph

How to Debug

  1. Validate in Mermaid Live Editor: https://mermaid.live/

    • Paste your diagram code
    • See errors highlighted in real-time
    • Test different syntax variations
  2. Check Browser Console:

    • Mermaid errors include line numbers
    • Look for stack traces with specific syntax issues
  3. Test with Minimal Diagram:

    • Start with simplest possible diagram
    • Add complexity incrementally
    • Identify which addition breaks rendering

Production Best Practice

The basic error handling in our component can be enhanced:

components/mermaid.tsx
mermaid.render(id, chart)
  .then(({ svg }) => {
    if (ref.current) {
      ref.current.innerHTML = svg
    }
  })
  .catch((error) => {
    // Log full error for developers
    console.error('Mermaid rendering error:', {
      chart,
      error: error.message,
      stack: error.stack
    })
    
    // Show friendly message to users
    if (ref.current) {
      ref.current.innerHTML = `
        <div style="
          border: 2px solid #ef4444;
          border-radius: 0.5rem;
          padding: 1rem;
          background: #fef2f2;
          color: #991b1b;
        ">
          <strong>⚠️ Unable to render diagram</strong>
          <p style="margin: 0.5rem 0 0 0; font-size: 0.875rem;">
            This diagram contains a syntax error.
            ${process.env.NODE_ENV === 'development'
              ? `Check the browser console for details.`
              : `Please report this issue.`
            }
          </p>
        </div>
      `
    }
  })

Key improvements:

  • Development mode: Shows detailed error for debugging
  • Production mode: Shows user-friendly message
  • Console logging: Preserves full error context
  • Visual feedback: Clear error styling

Making Diagrams Accessible

Screen readers can't interpret visual diagrams. Here's how to make them accessible:

Basic Accessibility (Current Implementation)

Our component includes minimal accessibility:

<div
  ref={ref}
  className="mermaid-container my-8"
  role="img"               // Announces "image" to screen readers
  aria-label="Mermaid diagram"  // Generic description
/>

Limitation: "Mermaid diagram" doesn't describe the content.

Better: Descriptive Labels

Pass meaningful descriptions:

Enhanced Mermaid Component
interface MermaidProps {
  chart: string
  ariaLabel?: string  // Optional descriptive label
}
 
export function Mermaid({ chart, ariaLabel }: MermaidProps) {
  // ... rendering logic
  
  return (
    <div
      ref={ref}
      className="mermaid-container my-8"
      role="img"
      aria-label={ariaLabel || 'Mermaid diagram'}
    />
  )
}

Usage in content:

<Mermaid
  chart="graph TD\n  A[Login]-->B[Validate]"
  ariaLabel="User authentication flow showing login, validation, and redirect steps"
/>

Best: Provide Text Alternative

For complex diagrams, include a text description:

Accessible Diagram Example
<div className="diagram-wrapper">
  <Mermaid
    chart="graph TD\n  A[Login]-->B[Validate]-->C[Dashboard]"
    ariaLabel="Three-step authentication process"
  />
  
  <details className="mt-4 text-sm text-gray-600 dark:text-gray-400">
    <summary className="cursor-pointer font-medium">
      📝 Text description of diagram
    </summary>
    <div className="mt-2 space-y-2">
      <p><strong>Process flow:</strong></p>
      <ol className="list-decimal list-inside space-y-1 ml-4">
        <li>User enters credentials at Login screen</li>
        <li>System validates credentials against database</li>
        <li>On success, user is redirected to Dashboard</li>
      </ol>
    </div>
  </details>
</div>

WCAG 2.1 Compliance

Success Criterion 1.1.1 (Non-text Content):

All non-text content that is presented to the user has a text alternative that serves the equivalent purpose.

Our approach:

  • Level A (Minimum): aria-label with description ✅
  • Level AA (Recommended): Text alternative in <details>
  • Level AAA (Enhanced): Long description linked via aria-describedby

Implementation tip: For frequently used diagrams (like architecture diagrams in multiple posts), create reusable components with baked-in descriptions.


Performance Testing Methodology

The performance comparisons in this series were measured systematically. Here's the methodology:

Test Setup

Tools Used:

  • Chrome DevTools Performance tab
  • Lighthouse CI
  • Network throttling (built into DevTools)

Test Environment:

Browser: Chrome 120+ (latest stable)
Device: Desktop (no CPU throttling)
Network: Throttled to "Fast 3G"
  - Download: 1.6 Mbps
  - Upload: 750 Kbps
  - RTT: 40ms
Cache: Disabled (simulates first-time visitor)
Extensions: Disabled (Incognito mode)

Why "Fast 3G"?

  • Represents global median mobile connection
  • Shows performance differences that 4G/WiFi might hide
  • Conservative estimate for user experience

Measurement Process

  1. Clear browser cache and storage
  2. Open DevTools → Performance tab
  3. Start recording
  4. Navigate to test page (cold load)
  5. Stop recording after page fully interactive
  6. Repeat 5 times for each architecture
  7. Calculate median of middle 3 runs (discard highest/lowest)

Metrics Captured

MetricWhat It MeasuresWhy It Matters
FCP (First Contentful Paint)When first content appearsUser sees page isn't blank
LCP (Largest Contentful Paint)When main content visibleUser sees meaningful content
TTI (Time to Interactive)When page fully respondsUser can actually interact

Our Results (Median of 3 runs):

ArchitectureFCPLCPTTIBundle Size
Interactive1.2s ±0.1s2.8s ±0.2s3.1s ±0.3s403 KB (v10.x)
Simple1.2s ±0.1s2.6s ±0.1s2.9s ±0.2s373 KB (v10.x)

Key Finding: 200ms faster TTI (6.5% improvement)

Real-World Context

On Fast 4G/WiFi (10+ Mbps):

  • Difference shrinks to ~50ms
  • Both architectures feel instant
  • Bundle size matters less

On Slow 3G (400 Kbps):

  • Difference grows to ~800ms
  • Every KB counts
  • Simple architecture provides noticeably better UX

Takeaway: Optimize for the slowest reasonable connection you expect users to have.

Reproducing These Tests

# Install Lighthouse CI
npm install -g @lhci/cli
 
# Run automated tests
lhci autorun --config=./lighthouserc.json
 
# Or use DevTools manually:
# 1. Open DevTools (F12)
# 2. Performance tab → Settings (gear icon)
# 3. Network: "Fast 3G", CPU: "No throttling"
# 4. Click Record → Navigate → Stop

Epilogue: When Simple Gets Even Simpler

The Real-World Performance Problem

After shipping the simple solution to production, I discovered a new issue: blog posts with multiple diagrams (5+) still had performance problems.

The Problem:

Post with 8 diagrams:
- Page load: 1.2s (fine)
- Mermaid.js loads: 373 KB
- All 8 diagrams render immediately
- TTI: 4.2s (not fine!)

Even though individual diagrams were simple, rendering all of them upfront blocked the main thread for ~3 seconds.

The Solution: Lazy Loading with Intersection Observer

Unlike Architecture 2's pan/zoom controls (solving imaginary problems), this addressed a real user experience issue: readers had to wait for diagrams they weren't viewing yet.

The fix: Only render diagrams when they're about to enter the viewport.

hooks/use-intersection-observer.ts
import { useEffect, useRef, useState } from 'react'
 
export function useIntersectionObserver(
  options: IntersectionObserverInit = {}
) {
  const [isIntersecting, setIsIntersecting] = useState(false)
  const targetRef = useRef<HTMLDivElement>(null)
 
  useEffect(() => {
    const target = targetRef.current
    if (!target) return
 
    const observer = new IntersectionObserver(
      ([entry]) => {
        // Only trigger once when entering viewport
        if (entry.isIntersecting) {
          setIsIntersecting(true)
          observer.disconnect()
        }
      },
      {
        rootMargin: '300px', // Start loading 300px before visible
        ...options,
      }
    )
 
    observer.observe(target)
 
    return () => observer.disconnect()
  }, [options])
 
  return { targetRef, isIntersecting }
}
components/mermaid-lazy.tsx
'use client'
 
import { Mermaid } from './mermaid'
import { useIntersectionObserver } from '@/hooks/use-intersection-observer'
 
export function MermaidLazy({ chart }: { chart: string }) {
  const { targetRef, isIntersecting } = useIntersectionObserver()
 
  return (
    <div ref={targetRef} className="min-h-[200px]">
      {isIntersecting ? (
        <Mermaid chart={chart} />
      ) : (
        <div className="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded" />
      )}
    </div>
  )
}

How it works:

  1. Placeholder shown for diagrams below the fold
  2. Intersection Observer watches when diagram approaches viewport
  3. 300px root margin ensures diagram loads before user scrolls to it
  4. One-time trigger - once loaded, stays loaded (no re-rendering)

Performance Impact

Before (eager loading):

MetricValue
TTI (8 diagrams)4.2s
Initial bundle exec3.1s
Diagrams rendered8 (all)

After (lazy loading):

MetricValue
TTI2.9s ⬇️ 31% faster
Initial bundle exec1.8s
Diagrams rendered2 (above fold)

Key improvement: Only diagrams above the fold render initially. Others render as user scrolls.

Why This Doesn't Contradict "Simple"

This is different from Architecture 2's pan/zoom controls because:

  1. Solves real problem: Multiple diagrams genuinely hurt performance
  2. Small implementation: ~50 lines vs 706 lines
  3. Uses platform APIs: Intersection Observer is built-in, not custom physics
  4. Zero maintenance: No event handling, no edge cases, no cross-browser bugs
  5. Progressive enhancement: Page works without it (just slower)

The Architecture 2 pan/zoom problem:

  • ❌ Solved imaginary problems (users didn't need pan/zoom)
  • ❌ 706 lines of custom code
  • ❌ Complex event handling and physics
  • ❌ Created new bugs (SVG pixelation)

This lazy loading solution:

  • ✅ Solves real problems (measured performance impact)
  • ✅ 50 lines using native APIs
  • ✅ No complex state or events
  • ✅ No new bugs introduced

The lesson: Complexity is justified when it solves measured problems with proportional solutions. Architecture 2 added massive complexity for theoretical benefits. This adds minimal complexity for measurable gains.

When to Use Lazy Loading

Use lazy loading if:

  • Posts regularly contain 4+ diagrams
  • Performance testing shows TTI > 3.5s
  • Users report slow page loads

Skip lazy loading if:

  • Most posts have 1-2 diagrams
  • TTI already < 3s
  • Diagrams are small/simple

Our case: Blog posts averaged 3 diagrams (no lazy loading needed), but technical deep-dives had 8+ (lazy loading essential). We conditionally enable it:

Usage in MDX
// Simple posts (1-3 diagrams): Use regular Mermaid
<Mermaid chart="graph TD..." />
 
// Complex posts (4+ diagrams): Use lazy variant
<MermaidLazy chart="graph TD..." />

Total added complexity: 50 lines. Performance improvement: 31% faster TTI on diagram-heavy posts.

This is pragmatic engineering: Add complexity only when benefits are measurable and proportional.


Conclusion

The journey from build-time rendering to interactive controls to a simple solution taught me that engineering is about making informed trade-offs.

What Worked

Client-side rendering - Simple, no build complexity
Code-splitting - Only load Mermaid when needed
CSS quality hints - Crisp rendering at all zoom levels
Native browser zoom - Free feature, zero code
Minimal complexity - 28 lines vs 706 lines

What Didn't

Build-time rendering - DOM dependencies, Puppeteer overhead
Pan/zoom controls - Nice-to-have, not need-to-have
Custom event handling - Complexity without proportional value
SVG transform scaling - Caused pixelation issues

Final Architecture

Loading diagram...

The numbers:

  • 706 lines of code deleted
  • 31 KB bundle size reduction
  • 0 interactive features added
  • 100% better maintainability

Sometimes the best code is the code you delete. Sometimes the best feature is the one you don't build. And sometimes, simple CSS properties solve problems that hundreds of lines of JavaScript cannot.

The pragmatic approach wins.

Documentation created during this journey:

External references:


🎉 Series Complete!

👈 Previous: Part 2: Building Interactive Controls I Didn't Need

Missed the beginning? Start from Part 1: When Build-Time Rendering Seemed Like a Good Idea

Want the complete journey? All three posts cover:

  1. Build-time SVG generation attempts (Part 1)
  2. Interactive pan/zoom implementation (Part 2)
  3. The simple solution that shipped (Part 3)

Have you built something complex, then deleted most of it? Share your pragmatic engineering stories.