Engineering Atlas › Low-Level Design

Drawing UML sequence diagrams

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Reading the axes

Horizontal position means nothing except identity: each column is one participant, which can be an object, a service, a queue or a human actor. Vertical position is time, but only as order. Equal spacing does not mean equal duration, and two arrows at the same height on different lifelines are not necessarily simultaneous.

Each lifeline is a total order of events: on the pricing lifeline, receiving Quote happens before sending GET rules. Across lifelines the order is partial. A message's send happens before its receive, and that chains through, which is exactly Lamport's happens-before relation drawn on paper. Two events on different lifelines with no chain of messages between them are concurrent, whatever their height on the page.

Messages

A solid line with a filled arrowhead is a synchronous call: the sender blocks until the reply. A solid line with an open arrowhead is an asynchronous send: the sender continues. A dashed line back is a reply, labelled with the return value or status. In Mermaid these are ->>, -) and -->>, and -x or --x ends a message with a cross to show it was lost or failed.

The activation bar on the receiver starts when a synchronous call arrives and ends when the reply leaves. Nested bars show a participant calling itself or being re-entered by a callback, which is often the first visual hint of a reentrancy bug.

An arrow into a participant box drawn halfway down is a constructor call, and an X ending a lifeline is destruction (Mermaid: create participant, destroy).

Combined fragments

A fragment is a labelled box around some messages:

Nesting more than two fragments deep is a sign the diagram is trying to be a program. Split it into one diagram per scenario: the happy path, the declined card, the inventory timeout.

Reviewing agent-written diagrams

Agents generating diagrams from code tend to draw every call as synchronous, omit failure replies, and put par around calls the code awaits one after another. Compare with a real trace: spans that never overlap were not parallel. The practice task generates the diagram from spans.

The same shape elsewhere

Sequence diagrams are the ordering shape. A database transaction history and a TCP capture (Wireshark's flow graph is a sequence diagram of segments) raise the same questions: what is ordered, what is concurrent, and what happens when one message is lost.

Down the stack

Layer 1The contract

A sequence diagram claims four things that can be checked against a running system. Messages on a lifeline occur in the drawn order. Every synchronous call gets exactly one reply or one failure, and the caller's activation encloses the callee's. Messages in different par regions may interleave in any order. Messages not shown are not part of the scenario, which is a weaker claim than "do not happen". The diagram says nothing about absolute time, so latency budgets belong in notes or in a separate SLO.

Layer 2The mechanism

Inside one process a synchronous arrow is a function call and the activation bar is the callee's stack frame: pushed on call, popped on return. A debugger's stack view at any instant is a vertical slice through the diagram's nested activations.

Across processes the mapping is distributed tracing (see the Distributed Tracing lesson for propagation and sampling). OpenTelemetry records each operation as a span with trace_id (16 bytes), span_id (8 bytes), parent_span_id, name, kind, start_time_unix_nano, end_time_unix_nano and status. One arrow between services is a pair of spans: a CLIENT span in the caller and a SERVER span in the callee whose parent is that client span, linked by the W3C traceparent header, a 55-character string such as 00-<32 hex trace id>-<16 hex parent id>-01. An asynchronous send is a PRODUCER span and a CONSUMER span, often connected with a span link rather than a parent, because the consumer may run long after the producer has finished. INTERNAL spans are work inside one participant and draw as nothing, or as a self-call.

A par fragment corresponds to sibling spans whose intervals overlap. A loop is a run of sibling spans with the same name. An alt is not visible in one trace at all; it appears only across many traces, as different children under the same parent.

Mermaid parses sequenceDiagram text with a Jison-generated grammar into participants and an ordered list of message and fragment events, then renders SVG itself. No graph layout engine is needed: participants are columns, events are rows, and the real work is measuring label text. PlantUML likewise lays out sequence diagrams without Graphviz. Jaeger draws the same data as a Gantt chart, keeping durations but hiding who called whom.

Layer 3The machine

On x86-64 a call pushes an 8-byte return address and the callee typically sets up a frame of a few dozen to a few hundred bytes; a call and return costs a few nanoseconds when the branch predictor guesses right. A cross-service arrow costs from about 100 microseconds within a rack to tens of milliseconds across regions, so a diagram with 12 synchronous hops in sequence has a latency floor you can read off the page before writing any code.

async and await break the frame mapping. Rust futures, C# async methods and JavaScript promises turn the suspended activation into a heap object holding the local variables, and the thread's stack unwinds while the call is pending. The diagram still shows one nested activation, but a stack trace taken in the callee no longer contains the caller. That is why runtimes added async stack traces and why tracing context travels in task-local or context objects instead of the stack.

Span timestamps come from different machines. NTP usually keeps hosts within a millisecond or so in one datacenter, but a fast child call can still appear to start before its parent or end after it. Trace viewers adjust for this skew, and any tool that infers ordering or parallelism from timestamps must tolerate it.

Why it's built this way

  1. Most design bugs are ordering bugs (a call made before a lock is taken, a reply lost after a timeout), so the notation makes order the main axis.
  2. Each participant runs sequentially but participants run concurrently, so each lifeline is a total order and the picture as a whole is only a partial order.
  3. Real interactions branch and repeat, so fragments add control flow without abandoning the time axis.
  4. Hand-drawn diagrams drift from code, so tracing systems record the same structure automatically, and the diagram can be regenerated from evidence.

Diagram

sequenceDiagram
  participant C as Client
  participant G as gateway
  participant P as pricing
  participant I as inventory
  participant Pay as payments
  C->>G: POST /checkout
  par quote and reserve
    G->>P: Quote
    P-->>G: total
  and
    G->>I: Reserve
    I-->>G: hold id
  end
  G->>Pay: Charge
  alt card accepted
    Pay-->>G: charge id
    G-->>C: 201 Created
  else declined
    Pay--xG: declined
    G-)I: Release hold
    G-->>C: 402 Payment Required
  end

Worked example

A checkout endpoint has a p50 of 180 ms and a target of 120 ms. The design doc's sequence diagram shows the gateway calling pricing and inventory inside a par fragment, then payments. Everyone believes pricing and inventory run in parallel.

An engineer pulls 500 traces and generates a diagram from each. In 480 of them the Quote and Reserve spans do not overlap: Quote runs from 30 to 70 ms and Reserve from 71 to 129 ms. The code awaited each call in turn:

quote = await pricing.quote(cart)
hold = await inventory.reserve(cart)

Running them with asyncio.gather makes them overlap, and the critical path drops from 40 + 58 = 98 ms to max(40, 58) = 58 ms, taking p50 from 180 to about 140 ms. The rest of the gap turns out to be payments waiting on an undersized connection pool.

The other 20 traces matter more: Reserve failed and the gateway still called Charge. The design diagram had no inventory failure branch, so nobody had designed one. The fix adds a break fragment to the diagram and an early return to the code.

Trade-offs

OptionStrengthsWeaknessesChoose when
Hand-drawn sequence diagramShows intent, includes failure paths you plan forDrifts from code, can be wrong about parallelismDesign review, before code exists
Diagram as code (Mermaid, PlantUML)Diffable, reviewed with codeStill written by handDesign docs in the repository
Generated from tracesShows what really happenedOne run only, no intent, sampling gapsDebugging, verifying a design
Trace Gantt viewReal durations and overlapHides caller and callee relationshipsLatency analysis

Pitfalls in production

Leadership lens

Check yourself

  1. Two arrows appear at the same height on different lifelines. Did they happen at the same time?

    Show answer

    Not necessarily. Height orders events only within a lifeline and along message chains; events with no chain between them are concurrent and may happen in either order.

  2. What pair of OpenTelemetry spans corresponds to one synchronous arrow between two services?

    Show answer

    A CLIENT span in the caller and a SERVER span in the callee, whose parent is the client span, connected by the propagated traceparent.

  3. Why does a stack trace from an async callee often not show the caller, while the sequence diagram does?

    Show answer

    The suspended caller's activation lives in a heap-allocated state object, not on the thread's stack, so unwinding the stack does not reach it.

  4. How would you check that a par fragment in the design is real?

    Show answer

    Look at traces: the sibling spans inside it must overlap in time. If they never overlap, the code is sequential.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →