The language
The .loadmap language
A .loadmap file defines a whole loadmap as text: the load and the controls, the flows and their routes, the boxes
of the diagram with what each one does and what can run out. @loadmap/dsl reads it into the same model and
view that defineModel and defineView build in TypeScript, so <Loadmap> draws it the same way. The editor
(@loadmap/editor, and the docs site's editor) shows it live as you type.
On this page
loadmap 1
title: "Photo uploads"
load "Uploads, × today" { max: 50; default: 5 }
param pods "Resizer pods" { options: [2, 4, 8]; default: 4 }
flow photos "photos" {
rate: 20 * load
route: api -> resizers
from: phones
}
node phones "Phones"
node api "API" {
pass
demand: flows.photos
capacity: estimated(1_500, "req/s", "vendor benchmark")
over: "uploads fail with 503s"
}
pool resizers "Resizers" `${pods} pods × 4 threads` {
workers { pods: pods; perPod: 4 }
demand: flows.photos * 0.03
capacity: measured(pods * 2, "cores", "pod CPU limit")
over: "photos wait and the lag grows"
}
phones -> api: "POST /photos"
api -> resizers
examples/checkout/checkout.loadmap and examples/alerts/alerts.loadmap are the two examples written this way. Their
tests check that each draws exactly what its TypeScript draws.
How it reads
- The first line is the version:
loadmap 1. A file written for a newer version says so instead of half working. - A statement with an id starts with its keyword:
param pods "Worker pods" { … }. The words after the keyword are its id and its label (and for a box, the line under it). - A property is
key: value, one per line or separated by;. - An edge is
a -> b, with an optional label after:. #starts a comment.
Boxes, stages and components
A box (node, cluster, pool) is what the diagram shows. Two more things can live in a box:
- A stage: what the dots do there. Write it as a statement inside the box:
pass,store,sink,fanout,cacheorlimitin a node, one or morequeuein a cluster, oneworkersin a pool. Its id is the box's id unless you give it one. A label after the keyword names it in the meters:pass "the gateway". - A component: what can run out. Write its lines on the box:
demand,capacity,over, and optionallyname,short,plural,describeandwhen. Its id is the box's id and its name is the box's label. A stage in the box uses this component unless it names another one withcomponent: …. A component without a box is a top-levelcomponent id "Name" { … }.
A route lists stages: route: gateway -> topic -> workers. It may also name a box that runs exactly one stage.
Formulas
Every value is a formula: a small subset of TypeScript expressions, so a lambda body from a TypeScript model pastes as it is.
- Literals: numbers (
20_000),"texts",true,false, and templates (`${pods} pods`). - Operators:
+ - * / %,< <= > >= == != === !==,&& || !,? :and parentheses. - Names:
loadand the parameters by their id;letnames;flows.x,at.stage.x,demand.x,util.xandderived.xfor the model's tables (at.stagealone is every flow reaching that stage);iinsidememberLabelandpodLabel, andvalueinsideformat.
- Calls: the built-in functions (listed at the end), the fits by their id, and the functions a host page provides.
- Capacities come only from
measured(value, unit, source),estimated(value, unit, source)or, for a limit someone set,configured(value, unit, source). A bare number is an error, so every capacity says where it came from.
Some properties must be constants (a priority, a unit). Others may read only the parameters (enabled).
Formulas are safe to share. The text is parsed into a tree and run as closures. There is no eval or
new Function, no global name, no property but the five tables, and caps on size and depth. Counts that size the
drawing are capped too: at most 64 members, 256 pods, 256 workers a pod and 1,024 partitions a flow.
Scenarios and growth
A scenario is a design to compare: the defaults plus some parameter values, never the load. The first one is the baseline the others are measured against, so name today's design first:
scenario "Today" {}
scenario "Proposed" { gateways: 2; pods: 6 }
growth { rate: 0.06; per: month; asOf: "2026-11-01" }
The page shows a button per scenario above the diagram, a table of where each one first runs out, and a headroom
chart with one line per scenario. Growth turns load multiples into dates: the load is 1× on asOf and grows by
rate every per, compounded, so 10× at 6 % a month is about 40 months later ("around February 2030"). Dates come
from asOf alone, never from today's date, so a page and its recordings say the same thing on any day.
A share link carries the scenarios in the text and the shown one in its values (&gateways=2&pods=6), so it opens
with that scenario's button pressed.
How sure, and what must hold
A capacity says how sure it is with a ± in its value, as a share (30%) or in its unit (± 1). +/- is the same
for a keyboard without ±:
capacity: estimated(20_000 * gateways ± 30%, "req/s", "vendor benchmark, not load-tested")
capacity: measured(pods * 2 +/- 5%, "cores", "pod CPU limit")
Every crossing is then also found with each capacity at its low bound and at its high bound, and the limits become ranges: "the 50 % target holds up to between 7.4× and 10×". The ranking, the first limit and the presets use the pessimistic edge (every capacity low), where it may already happen. The headroom chart draws each scenario as a band with a bracket where it crosses the target and 100 %, and when the range is wide, a line under the verdict names the capacity whose measurement would change or narrow the answer most. Fits stay exact.
An expect line is something that must keep holding, as minimum load multiples on that pessimistic edge:
expect "Today keeps 7×" { target: 7 }
expect "Proposed keeps 14×" { scenario: Proposed; target: 14 }
expect { component: db; full: 100 }
target is where the 50 % target is reached (by the first limit, or by component), full where 100 % is; without
a scenario the defaults are checked. The editor marks a failing expectation as a warning on its line and still
draws the diagram. loadmap check fails on it, so a change that loses headroom cannot land unseen:
loadmap check examples/checkout/checkout.loadmap # exits 1 when an expectation fails or the file has an error
checkout.loadmap:26:1 error: "Today keeps 8×" fails: expected at least 8×, but the 50 % target holds up to between 7.4× and 10×, limited by the API gateway (capacity estimated ±30 %)
26 | expect "Today keeps 8×" { target: 8 }
| ^^^^^^^^^^^^^^^^^^^^^^^
checkout.loadmap: 1 of 2 expectations failed
Under GitHub Actions (or with --format github) each problem is also written as an annotation, so it shows on its
line in the pull request.
Building blocks
Four shapes of real systems have statements of their own, so a model says what they do instead of working it out in formulas. Each has a template to start from.
What reaches a stage. at.db.reads is the rate of reads reaching the stage db, and at.db every flow's. It
is the flow's rate, less what a cache answered and what a rate limiter turned away before it; an overloaded pass
does not cut it, so each component's limit stays the one it has if what is in front keeps up. A component without a
demand line handles what reaches its stages, in units per second:
node db "Database" {
store
capacity: estimated(3_000 ± 30%, "queries/s") # no demand: the misses the cache lets through
over: "queries queue on the primary"
}
A cache answers a share of the units and sends them back where they came from; the rest carry on. With capacity
lines it is a component too, and past capacity it rejects like a pass:
node cache "Cache" {
cache { hit: hit }
capacity: estimated(50_000, "ops/s")
over: "lookups time out and fall through"
}
An autoscaled pool adds pods while the running ones are above target, between min and max. Its capacity is
the pool at max pods, so it runs out where it cannot grow further; the box draws every pod it may run and dashes
the ones it does not run now, and a meter counts them:
pool api "API" {
workers { perPod: 4; autoscale { min: 2; max: maxPods; target: 0.7 } }
demand: flows.requests * 0.005
capacity: measured(maxPods * 1, "cores", "1 core a pod, at most pods")
over: "the pool is at its maximum and requests queue"
}
A rate limiter is a limit stage. Its capacity is the limit, configured(…): a number someone set, exact, so it
takes no ±. Above it the share over is turned away ("17 % rejected at the limiter"), and the stages after it see
only what got through, so the service behind a limit may never run out.
Retries send a unit turned away on its route (by a pass, limit or cache past capacity) again, up to times
more times. A budget caps the extra attempts at that share of the flow. A retry rule reads only the load and the
parameters: retries change every other number, so they cannot depend on one.
flow requests "requests" {
rate: 1_000 * load
retry { times: 3; budget: 0.1 }
route: gateway -> service
from: clients
}
The attempts and the rejections depend on each other, so loadmap solves for where they agree: with n retries
against a bottleneck of capacity C, each attempt fails with share p = (1 − C/λ)^(1/(n+1)) and the attempts are
C ÷ (1 − p). Below capacity nothing changes. Past it, retries save no one (the same λ − C fail, after more
tries) but multiply what everything in front sees: with 3 retries a gateway of 2,000 req/s before a service of
1,000 runs out at 1,067 req/s instead of 2,000. flows.requests is the attempts; the meters show the attempts per
request and the share failing after every retry, and the retries are drawn as rings.
A story
step lines walk a reader through the model in order: what to look at, and the values to show it at. Each step starts
from the defaults (or from its scenario), then takes its own values, so it looks the same whichever step came before.
focus outlines a component's box:
step "An ordinary peak: every box is under half its capacity." { load: 1; title: "An ordinary peak" }
step "At 20×, the gateway and the workers are near full." { load: 20; focus: gateway }
step "The proposal doubles both: at 20× they are back under half." { scenario: Proposed; load: 20; focus: workers }
The page shows a bar above the diagram with the step's text, play, back and next (← and → while the bar has the
focus). Back and next show a step at once. Play tells it on the diagram: the whole diagram first, then for each step a
close-up on what it changes (the box it points at and any box it reshapes; else what it reshapes; else the boxes whose
state changes; else the first limit), the change, and the whole diagram again for the step's seconds. A short
title names the step on the diagram meanwhile; without one it says what changes ("Users 1× → 20×"). With reduced
motion the camera cuts between the shots. The component that runs out first wears a ring and a tag; it breathes while
the story plays and once it is past the target.
loadmap record x.loadmap --out story.gif --story --caption plays it the same way, from the defaults, the title on the
board and the step's text below it; the same options give the same file. A step's own properties are scenario,
focus, title and seconds; every other line is a value.
A step that changes the structure (more pods, another scenario) is laid out on its own and the boxes move to their new
places: what stays slides and resizes, what is new fades in. Each step, scenario, preset and choice of a select or
switch takes only the room it needs, over every load and every value of the range controls, so dragging a slider never
moves a box, and a step looks the same whichever came before. layout: stable gives every box one place instead, the largest it needs anywhere the model
reaches (its scenarios, steps, presets and controls): no step moves a box, a pool that gains pods grows where it
stands, and every step pays for the room of the largest. transition: 1.2 makes a change take 1.2 seconds (0.8 by
default; 0 cuts, and so does a reader who asked for reduced motion). A recording shows it too.
Direction and tiers
The diagram runs right, or down when it fans out wider than it is long: when its widest layer has more boxes than its
longest chain. Every edge counts, whatever its when, so the direction never turns with the values. direction: right or direction: down says which way (layout: right and layout: down, its old name, still work, and loadmap fmt rewrites them); narrow screens always run down.
The boxes nothing feeds stand first and the sinks and stores that feed nothing stand last. To cut the diagram into bands across the flow (edge, services, data), declare tiers in the order the flow meets them and put a box in one:
tier edge "Edge"
tier services "Services"
tier data "Data"
node gateway "Gateway" { tier: edge; pass }
pool api "API" { tier: services; workers { pods: pods; perPod: 8 } }
node db "Database" { tier: data; store }
Each tier is a lane behind its boxes: every other one shaded, a line between them, and a header with its name in
capitals at the lane's start, a strip across the top (a column on the left running down, and on a phone, the name
reading up). A name wider than its lane takes two lines, and every header on the board then takes two. A box without
a tier stands in the tier of the boxes that feed it, or of those it feeds when nothing feeds it, so a definition names
the tier where it changes. In TypeScript:
tiers: { edge: { label: 'Edge' }, … } in the view, tier: 'edge' on a box.
Full screen
The diagram's Full screen fits the screen without scrolling: the diagram as wide as it, and below it the panels
of the page in a compact form, each of which opens whole. fullScreen keeps only the ones it lists:
fullScreen: [verdict, live, controls]
The names are verdict, live (the meters), limits (where it runs out, in order), controls, headroom,
compare (the scenarios side by side) and assumptions. <Loadmap fullScreen={[...]}> takes the same list.
A switch in full screen, or the F key, turns it into focus: the diagram, the story, and the verdict and the load
slider when fullScreen lists them. fullScreenMode: focus opens full screen there
(<Loadmap fullScreenMode="focus">); the default is panels.
What stays code
A host page can give formulas more functions:
compile(source, { functions: { headroom: (x: number) => x * 1.2 } })
A shared link opened where those functions are missing reports which one it needs. Anything beyond formulas stays in the TypeScript API, which reads the same.
Layout
loadmap fmt (and Format in the editor) lays a definition out the one way: two spaces a block, a statement a
line, at most one blank line, one space around operators. Numbers, texts, templates and parentheses stay as written,
and a block written on one line stays on one line while it fits in 120 columns. format(source) from @loadmap/dsl
does the same in code; a text with a syntax error comes back unchanged, with its diagnostics.
Errors
Every error has a line and a column, and parsing goes on after one, so a file shows all its problems at once. Names that are almost right get a suggestion:
checkout.loadmap:102:17 error: unknown flow "order"
102 | demand: flows.order + flows.events
| ^^^^^ did you mean "orders"?
compile also runs the model at its defaults, at every preset and in every scenario. A formula that fails there,
such as a loop between two lets, is marked where it is written.
In code
import { compile, formatDiagnostic } from '@loadmap/dsl'
const r = compile(source)
if (!r.ok) for (const d of r.diagnostics) console.error(formatDiagnostic(d, source, 'my.loadmap'))
const { model, view, options } = r.definition!
// <Loadmap model={model} view={view} {...options} />
r.expectations holds each expect line with its place and its check() result; problems(r) returns the
diagnostics with every failing expectation added (as a warning, or as problems(r, 'error')).
completions(source, offset) lists what may be typed at a place, ranked by what fits there, with snippets in the
language server protocol's ${1:field} form. hover(source, offset, definition?) says what the word there is (a
statement, a property, a name the definition declares, a builtin) and, given the compiled definition, its values at the
defaults: a flow's rate, what reaches a stage, a component's utilization and where it runs out. signatureHelp(source, offset) names the arguments of the builtin or fit the cursor is in. quickFixes(source, diagnostics) gives each
diagnostic's fixes as text changes (the did-you-mean, a missing property, a bare number made a capacity).
definition and references(source, offset) find where a name is declared and every place it is written, and
rename(source, offset, name) renames it everywhere: a box's id with the stage and the component that take it. encodeShare and decodeShare write and read share
links: #v1. followed by the text deflated and base64url-encoded, then the values that differ from the defaults
(&load=30). The link carries everything, so nothing goes to a server.
Write it in VS Code, Neovim, Helix or any editor with a language server: In your editor. Every statement and property is listed in the reference.