loadmap 0.1 npm GitHub
Theme

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

Boxes, stages and components

A box (node, cluster, pool) is what the diagram shows. Two more things can live in a box:

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.

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.