Drawing diagrams with LLMs

2026-09-18

Diagrams are more effective at communicating ideas than prose alone, and LLMs are great at drawing and updating these diagrams quickly. But it's easy to get caught up in the complexities of how to lay out components, and keep them looking neat. This is where "diagrams as code" tools like D2 and Mermaid are effective for keeping conversations focused on content without worrying about the layout.

LLMs read and write D2 and Mermaid script directly, without needing to see diagrams laid out or rendered. This means a greater than 90% saving of token spend versus using an SVG directly, and makes feedback much faster. With this comparison, plus D2's newly open-sourced TALA renderer, [1] I'll be switching to D2 for my system design diagrams.

Throughout this article we'll focus on a simple event processing service, rendering it as D2, Mermaid and freestyle SVG diagrams. Notice the difference between constraints, layering, and freestyle layouts.

All of the conversations, diagrams and token metrics were captured through my Diagram Chat tool (which renders D2 and Mermaid client-side), and deepseek-4.1-flash as the LLM.

Updating a D2 diagram

Consider this chat conversation with an LLM. We can request a simple change to a diagram, passing in the D2 script. The chat client renders the diagrams to SVG, but the LLM operates on the script directly and never sees the rendered diagram.

User

Update this D2 diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram rendered from D2, showing event sources flowing through an event queue into validation and deduplication, with retry paths and a dead letter queue
markdown
```d2
direction: right
event_sources: Event sources {
  shape: cloud
}
event_queue: Event queue {
  shape: queue
}
dlq: Dead letter queue {
  shape: queue
}
service: Event processing service {
  validate: validate {
    shape: diamond
  }
  dedupe: deduplicate {
    shape: diamond
  }
  process: further processing {
    shape: step
  }
}
event_sources -> event_queue: publish
event_queue -> service.validate: dequeue
service.validate -> service.dedupe: valid
service.validate -> dlq: invalid
service.dedupe -> service.process: new
service -> event_queue: retry
```
Assistant

Here's the diagram with the new decision node.

Updated event processing diagram with a retry counter node that routes to the dead letter queue after three failed attempts
markdown
direction: right

event_sources: Event sources {
  shape: cloud
}

event_queue: Event queue {
  shape: queue
}

dlq: Dead letter queue {
  shape: queue
}

service: Event processing service {
  validate: validate {
    shape: diamond
  }
  dedupe: deduplicate {
    shape: diamond
  }
  retry_counter: retry counter {
    shape: step
    label: "retry counter\n(max 3 attempts)"
  }
  process: further processing {
    shape: step
  }
  discard: discard {
    shape: rectangle
  }
}

event_sources -> event_queue: publish
event_queue -> service.validate: dequeue
service.validate -> service.dedupe: valid
service.validate -> dlq: invalid
service.dedupe -> service.process: new
service.dedupe -> service.discard: duplicate
service.validate -> service.retry_counter: transient failure {
  style: {
    stroke-dash: 4
    stroke: "#9aa0a6"
    font-color: "#9aa0a6"
  }
}
service.dedupe -> service.retry_counter: transient failure {
  style: {
    stroke-dash: 4
    stroke: "#9aa0a6"
    font-color: "#9aa0a6"
  }
}
service.retry_counter -> event_queue: "retry (attempt < 3)" {
  style: {
    stroke-dash: 4
    stroke: "#9aa0a6"
    font-color: "#9aa0a6"
  }
}
service.retry_counter -> dlq: "retries exhausted (attempt = 3)" {
  style: {
    stroke: "#d93025"
    font-color: "#d93025"
    bold: true
  }
}

The whole update only cost a few hundred tokens: 361 in, 666 out (460 thinking + 201 response).

Updating an SVG diagram

Let's compare this to a full SVG instead. The input SVG was originally drawn by an LLM following the same spec as the others.

User

Update this SVG diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram SVG, showing event sources flowing through an event queue into validation and deduplication, with retry paths and a dead letter queue
xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1620 800" width="1620" height="800"
    font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
    <defs>
        <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#334155"></path>
        </marker>
        <marker id="arrow-muted" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#94a3b8"></path>
        </marker>
    </defs>


    <!-- ============ SERVICE BOUNDARY ============ -->
    <rect x="600" y="130" width="960" height="590" rx="16" fill="#f8fafc" stroke="#94a3b8"
        stroke-width="2"></rect>
    <text x="626" y="168" font-size="16" font-weight="700" fill="#64748b" letter-spacing="0.6">Event
        processing service</text>

    <!-- ============ EXTERNAL: CLOUD ============ -->
    <path d="M 45 350 Q 35 300 80 290 Q 90 255 135 260 Q 180 250 195 290 Q 230 305 215 350 Z"
        fill="#ffffff" stroke="#334155" stroke-width="2" stroke-linejoin="round"></path>
    <text x="128" y="318" text-anchor="middle" font-size="14" fill="#0f172a">External</text>
    <text x="128" y="336" text-anchor="middle" font-size="14" fill="#0f172a">systems</text>

    <!-- ============ EXTERNAL: EVENT QUEUE ============ -->
    <rect x="340" y="260" width="160" height="80" rx="10" fill="#e0f2fe" stroke="#0369a1"
        stroke-width="2"></rect>
    <text x="420" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">Event
        queue</text>

    <!-- ============ EXTERNAL: DLQ ============ -->
    <rect x="330" y="580" width="180" height="80" rx="10" fill="#fee2e2" stroke="#b91c1c"
        stroke-width="2"></rect>
    <text x="420" y="626" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">Dead
        letter queue</text>

    <!-- ============ VALIDATE (diamond) ============ -->
    <polygon points="780,240 890,300 780,360 670,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="780" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        validate</text>

    <!-- ============ DEDUPLICATE (diamond) ============ -->
    <polygon points="1100,240 1210,300 1100,360 990,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="1100" y="306" text-anchor="middle" font-size="13.5" font-weight="600" fill="#0f172a">
        deduplicate</text>

    <!-- ============ FURTHER PROCESSING (open-ended continuation) ============ -->
    <path d="M 1510 260 H 1330 V 340 H 1510" fill="#f0fdf4" stroke="#15803d" stroke-width="2"
        stroke-linejoin="round"></path>
    <line x1="1510" y1="260" x2="1510" y2="340" stroke="#15803d" stroke-width="2"
        stroke-dasharray="6 5"></line>
    <text x="1416" y="306" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">further
        processing</text>

    <!-- ============ DISCARD ============ -->
    <rect x="1010" y="510" width="180" height="80" rx="10" fill="#f1f5f9" stroke="#64748b"
        stroke-width="2"></rect>
    <text x="1100" y="556" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        discard</text>

    <!-- ============ PRIMARY FLOW ARROWS ============ -->
    <!-- external systems -> event queue -->
    <path d="M 208 300 H 340" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="274" y="288" text-anchor="middle" font-size="12" fill="#334155">publish events</text>

    <!-- event queue -> validate (enters boundary) -->
    <path d="M 500 300 H 670" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="585" y="288" text-anchor="middle" font-size="12" fill="#334155">consume</text>

    <!-- validate - valid -> deduplicate -->
    <path d="M 890 300 H 990" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="940" y="288" text-anchor="middle" font-size="12" fill="#334155">valid</text>

    <!-- deduplicate - new -> further processing -->
    <path d="M 1210 300 H 1330" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1270" y="288" text-anchor="middle" font-size="12" fill="#334155">new</text>

    <!-- deduplicate - duplicate -> discard -->
    <path d="M 1100 360 V 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1112" y="440" font-size="12" fill="#334155">duplicate</text>

    <!-- validate - invalid -> DLQ (exits boundary) -->
    <path d="M 780 360 V 620 H 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="645" y="608" text-anchor="middle" font-size="12" fill="#334155">invalid</text>

    <!-- ============ RETRY (dashed, muted) ============ -->
    <!-- validate -> event queue -->
    <path d="M 780 240 V 100 H 390 V 260" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="585" y="88" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>

    <!-- deduplicate -> event queue -->
    <path d="M 1100 240 V 70 H 450 V 260" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="775" y="58" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>
</svg>
Assistant

Here's the diagram with the new decision node.

Updated SVG diagram with a retry count decision diamond and dashed paths routing exhausted retries to the dead letter queue
xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1620 800" width="1620" height="800"
    font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
    <defs>
        <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#334155"></path>
        </marker>
        <marker id="arrow-muted" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6.5"
            markerHeight="6.5" orient="auto">
            <path d="M0,0 L10,5 L0,10 Z" fill="#94a3b8"></path>
        </marker>
    </defs>


    <!-- ============ SERVICE BOUNDARY ============ -->
    <rect x="600" y="130" width="960" height="590" rx="16" fill="#f8fafc" stroke="#94a3b8"
        stroke-width="2"></rect>
    <text x="626" y="168" font-size="16" font-weight="700" fill="#64748b" letter-spacing="0.6">Event
        processing service</text>

    <!-- ============ EXTERNAL: CLOUD ============ -->
    <path d="M 45 350 Q 35 300 80 290 Q 90 255 135 260 Q 180 250 195 290 Q 230 305 215 350 Z"
        fill="#ffffff" stroke="#334155" stroke-width="2" stroke-linejoin="round"></path>
    <text x="128" y="318" text-anchor="middle" font-size="14" fill="#0f172a">External</text>
    <text x="128" y="336" text-anchor="middle" font-size="14" fill="#0f172a">systems</text>

    <!-- ============ EXTERNAL: EVENT QUEUE ============ -->
    <rect x="340" y="260" width="160" height="80" rx="10" fill="#e0f2fe" stroke="#0369a1"
        stroke-width="2"></rect>
    <text x="420" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">Event
        queue</text>

    <!-- ============ EXTERNAL: DLQ ============ -->
    <rect x="330" y="580" width="180" height="80" rx="10" fill="#fee2e2" stroke="#b91c1c"
        stroke-width="2"></rect>
    <text x="420" y="626" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">Dead
        letter queue</text>

    <!-- ============ VALIDATE (diamond) ============ -->
    <polygon points="780,240 890,300 780,360 670,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="780" y="306" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        validate</text>

    <!-- ============ DEDUPLICATE (diamond) ============ -->
    <polygon points="1100,240 1210,300 1100,360 990,300" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="1100" y="306" text-anchor="middle" font-size="13.5" font-weight="600" fill="#0f172a">
        deduplicate</text>

    <!-- ============ FURTHER PROCESSING (open-ended continuation) ============ -->
    <path d="M 1510 260 H 1330 V 340 H 1510" fill="#f0fdf4" stroke="#15803d" stroke-width="2"
        stroke-linejoin="round"></path>
    <line x1="1510" y1="260" x2="1510" y2="340" stroke="#15803d" stroke-width="2"
        stroke-dasharray="6 5"></line>
    <text x="1416" y="306" text-anchor="middle" font-size="13" font-weight="600" fill="#0f172a">further
        processing</text>

    <!-- ============ DISCARD ============ -->
    <rect x="1010" y="510" width="180" height="80" rx="10" fill="#f1f5f9" stroke="#64748b"
        stroke-width="2"></rect>
    <text x="1100" y="556" text-anchor="middle" font-size="14" font-weight="600" fill="#0f172a">
        discard</text>

    <!-- ============ RETRY DECISION (new diamond) ============ -->
    <polygon points="420,50 490,110 420,170 350,110" fill="#ffffff" stroke="#334155"
        stroke-width="2" stroke-linejoin="round"></polygon>
    <text x="420" y="106" text-anchor="middle" font-size="12" font-weight="600" fill="#0f172a">retry
        count</text>
    <text x="420" y="124" text-anchor="middle" font-size="12" font-weight="600" fill="#0f172a">=
        3?</text>

    <!-- ============ PRIMARY FLOW ARROWS ============ -->
    <!-- external systems -> event queue -->
    <path d="M 208 300 H 340" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="274" y="288" text-anchor="middle" font-size="12" fill="#334155">publish events</text>

    <!-- event queue -> validate (enters boundary) -->
    <path d="M 500 300 H 670" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="585" y="288" text-anchor="middle" font-size="12" fill="#334155">consume</text>

    <!-- validate - valid -> deduplicate -->
    <path d="M 890 300 H 990" fill="none" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"></path>
    <text x="940" y="288" text-anchor="middle" font-size="12" fill="#334155">valid</text>

    <!-- deduplicate - new -> further processing -->
    <path d="M 1210 300 H 1330" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1270" y="288" text-anchor="middle" font-size="12" fill="#334155">new</text>

    <!-- deduplicate - duplicate -> discard -->
    <path d="M 1100 360 V 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="1112" y="440" font-size="12" fill="#334155">duplicate</text>

    <!-- validate - invalid -> DLQ (exits boundary) -->
    <path d="M 780 360 V 620 H 510" fill="none" stroke="#334155" stroke-width="2"
        marker-end="url(#arrow)"></path>
    <text x="645" y="608" text-anchor="middle" font-size="12" fill="#334155">invalid</text>

    <!-- ============ RETRY (dashed, muted) ============ -->
    <!-- validate -> retry decision -->
    <path d="M 780 240 V 110 H 490" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="700" y="100" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>

    <!-- deduplicate -> retry decision (merges into the same path) -->
    <path d="M 1100 240 V 70 H 560 V 110" fill="none" stroke="#94a3b8" stroke-width="2"
        stroke-dasharray="7 6"></path>
    <text x="900" y="60" text-anchor="middle" font-size="12" fill="#94a3b8">retry</text>

    <!-- retry decision - no (under limit) -> back onto the event queue -->
    <path d="M 420 170 V 260" fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="7 6"
        marker-end="url(#arrow-muted)"></path>
    <text x="432" y="222" font-size="12" fill="#94a3b8">no</text>

    <!-- retry decision - yes (3rd retry) -> dead letter queue -->
    <path d="M 350 110 H 290 V 292 A 8 8 0 0 1 290 308 V 620 H 330" fill="none" stroke="#94a3b8"
        stroke-width="2" stroke-dasharray="7 6" marker-end="url(#arrow-muted)"></path>
    <text x="302" y="424" font-size="12" fill="#94a3b8">3rd retry</text>
</svg>

Just one turn, and the tokens have already taken a bite out of the context window: 2,113 in, 15,017 out (12,739 thinking + 2,278 response).

Updating a Mermaid diagram

The D2 and SVG diagrams contrasted on token consumption, but Mermaid is a more fair comparison. Let's make a similar change.

User

Update this Mermaid diagram. A new decision node between the service and the queue. if it's the 3rd retry, send it to the deadletter queue.

Event processing diagram rendered from Mermaid, showing the same flow with a layered left-to-right layout
xml
```mermaid
flowchart LR
    ES@{ shape: cloud, label: "Event sources" }
    EQ[["Event queue"]]
    DLQ[["Dead letter queue"]]

    subgraph EPS["Event processing service"]
        direction LR
        V{"Validate"}
        D{"Deduplicate"}
        FP["Further processing …"]
        DISC["Discard"]
    end

    ES -->|"publish events"| EQ
    ES -->|"undeliverable events"| DLQ
    EQ -->|"deliver batch"| V
    V -->|"valid"| D
    V -->|"invalid"| DLQ
    D -->|"new"| FP
    D -->|"duplicate"| DISC
    V -.->|"retry"| EQ
    D -.->|"retry"| EQ
```
Assistant

Here's the diagram with the new decision node.

Updated Mermaid diagram with a third-retry decision diamond routing exhausted retries to the dead letter queue, but with a cramped layout
markdown
```mermaid
flowchart LR
    ES@{ shape: cloud, label: "Event sources" }
    EQ[["Event queue"]]
    DLQ[["Dead letter queue"]]
    RC{"3rd retry?"}

    subgraph EPS["Event processing service"]
        direction LR
        V{"Validate"}
        D{"Deduplicate"}
        FP["Further processing …"]
        DISC["Discard"]
    end

    ES -->|"publish events"| EQ
    ES -->|"undeliverable events"| DLQ
    EQ -->|"deliver batch"| V
    V -->|"valid"| D
    V -->|"invalid"| DLQ
    D -->|"new"| FP
    D -->|"duplicate"| DISC
    V -.->|"retry"| RC
    D -.->|"retry"| RC
    RC -.->|"yes"| DLQ
    RC -.->|"no"| EQ
```

Mermaid's token consumption is much better: 397 in, 696 out (475 thinking + 221 response). But the layout is starting to get weird...

Direct comparisons

The tokens in this table are samples. In practice, I saw ±40% variance in these, depending on how long the LLM took to think, and the complexity of its response. However the scale still stands.

D2 SVG Mermaid
Input tokens 361 2,113 397
Thinking tokens 460 12,739 475
Response tokens 201 2,278 221

Layout engines save a lot of tokens

D2 and Mermaid produce similar output token counts, but SVG is roughly 20 times more expensive. This is partially due to the verbosity of the SVG format (very flexible, fully manual layout), but mostly because the LLM is performing the layout manually as part of the "thinking" stage. D2 and Mermaid just delegate that to the rendering engine.

...but not all layout engines are equal

The differences we see between D2 and Mermaid generally come down to how the layout engine works:

TALA's constraint-based layout optimises for short edges and balanced whitespace. Dagre and ELK's layered graph style (AKA Sugiyama graph style) assigns every node to a discrete rank. Each rank becomes a column, and the diagram grows horizontally.

The SVG is effectively a free for all. You might end up with a legible diagram, you might not. But every coordinate and path string is a token the model produced, and you can't verify its correctness without looking at it.

What's next?

LLMs can generate D2 code as comfortably as Mermaid code, but most agent tooling can't render it yet. Mermaid shows up extensively in markdown documents and LLM harnesses, while D2 has far less support. I had to build Diagram Chat with a custom rendering plugin to test D2 with TALA alongside Mermaid and SVG, and most agent harnesses would need similar work. Now that TALA is open-sourced, [1] though, it could land in Mermaid itself, which would also close that gap.

In the meantime, for iterating on diagrams over several turns, D2 with Tala is the format that holds up. You may need to vibe-code your own renderer, but it's worthwhile for the diagram quality and token consumption.

References

  1. ⬆️ TALA is open-source, Terrastruct

If you have feedback or questions about this article, let's catch up via LinkedIn or email.

Now (2026-05-26)Drawing diagrams with LLMsAutomation Bites: Session digestsShare tokens instead of IDsAutomating automationsSmartifying your devices