Lesson 03 / 10
Flow-Based Management
Measuring work-in-progress limits and flow metrics: lead time, cycle time, and throughput are defined in the same run, the WIP limit is scanned from one to sixteen, and idle capacity below the limit is counted against work effort lost to switching above it.
Contents
Every measurement in the previous lesson rested on a rhythm assumption: a work item can only start at the next planning session and can only be accepted at the end of the iteration. Once the rhythm is removed, no batch remains, and a work item is pulled the moment it is ready. What is left is a single control lever — how many items are open at the same time. This lesson scans that number from one end to the other.
For the scan to mean anything, the three metrics must first be separated. Lead time is the total duration from the day an item is requested to the day it reaches operations. Cycle time is the duration from the day an item is pulled into work to the day it finishes — lead time with the portion spent queued removed. Throughput is the number of items finished per unit of time. All three come out of the same run, and each points to a different decision. A work-in-progress (WIP) limit is a backpressure lever; because queueing mechanics and backpressure are measured elsewhere in this curriculum, what is counted here is not the queue itself but the limit’s effect on human flow. The through-line is the regional library network, and it is fiction.
Three Metrics, One Run
The block below is a model, not a measurement.
PM13 — sixty work items are derived from a generator; the generator is self-written and the seed is visible. Every item has a size (2–8 person-days) and an arrival day; the same set is used at every limit value.
PM14 — an item can receive at most 1 person-day per day, because only one person works on it. This is the rule that decides what is lost below the limit: with two items open, a four-person team can spend at most 2 person-days a day, and the remaining two people sit idle.
PM15 — once the number of open items exceeds the number of people, part of the day goes to switching. Every extra open item per person takes away 15% of that day’s capacity. This is the rule that decides what is lost above the limit.
PM16 — the WIP limit caps the number of open items. An item that arrives while the limit is full waits in the queue, and that wait counts toward lead time, not cycle time.
PM17 — items are pulled in arrival order, and an item once started is never set aside half-finished. Changing the priority order would change the distribution of lead time; holding it fixed ensures the only thing that changes in the scan is the limit itself.
// flow.mjs — work-in-progress-limited pull system (model); flow metrics defined at a single limit import { writeFileSync } from "node:fs"; const TEAM = 4; // team const PENALTY = 0.15; // daily loss per person for every extra open item // PM13: the generator is self-written, the seed is visible; the same item set is used at every limit. let seed = 20250715; const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; let t = 0; const ITEM = [...Array(60)].map((_, id) => { t += rand() < 0.35 ? 0 : 1; // arrivals average around 1.5 items per day return { id, size: 2 + Math.floor(rand() * 7), arrival: t }; }); function run(wip) { const queue = ITEM.map((k) => ({ ...k, remaining: k.size, start: -1, end: -1 })); const open = []; let day = 0, idleCapacity = 0, switchingLoss = 0; while (queue.some((k) => k.end < 0)) { day += 1; for (const k of queue) // the WIP limit's slack pulls in items if (open.length < wip && k.start < 0 && k.arrival <= day) { k.start = day; open.push(k); } const n = open.length; if (n === 0) { idleCapacity += TEAM; continue; } // PM15: with more than one open item per person, part of the day goes to switching const effective = TEAM * (1 - PENALTY * Math.max(0, n / TEAM - 1)); switchingLoss += TEAM - effective; const share = Math.min(1, effective / n); // PM14: an item can get at most 1 person-day per day idleCapacity += Math.max(0, effective - share * n); for (const k of [...open]) { k.remaining -= share; if (k.remaining <= 1e-9) { k.end = day; open.splice(open.indexOf(k), 1); } } } const measurements = queue.map((k) => ({ id: k.id, size: k.size, leadTime: k.end - k.arrival + 1, cycle: k.end - k.start + 1, wait: k.end - k.start + 1 - k.size, queued: k.start - k.arrival, })); const avg = (f) => measurements.reduce((a, x) => a + f(x), 0) / measurements.length; return { wip, day, measurements, leadTime: avg((x) => x.leadTime), cycle: avg((x) => x.cycle), queued: avg((x) => x.queued), waitShare: avg((x) => x.wait) / avg((x) => x.cycle), throughput: ITEM.length / day, idleCapacity: +idleCapacity.toFixed(1), switchingLoss: +switchingLoss.toFixed(1) }; } const SCAN = [1, 2, 3, 4, 5, 6, 8, 10, 12, 16].map(run); writeFileSync("scan.json", JSON.stringify(SCAN.map(({ measurements, ...r }) => r))); const five = SCAN[4]; console.log(`work items: ${ITEM.length}, base work: ${ITEM.reduce((a, k) => a + k.size, 0)} person-days, ` + `last arrival: day ${ITEM[ITEM.length - 1].arrival}, team ${TEAM} (seed ${20250715})`); console.log(`\nWIP limit ${five.wip}: three metrics on the first eight items (days):`); console.log(`${"item".padStart(6)}${"size".padStart(7)}${"queued".padStart(10)}` + `${"cycle time".padStart(15)}${"of which wait".padStart(17)}${"lead time".padStart(15)}`); for (const x of five.measurements.slice(0, 8)) console.log(`${String(x.id).padStart(6)}${String(x.size).padStart(7)}${String(x.queued).padStart(10)}` + `${String(x.cycle).padStart(15)}${String(x.wait).padStart(17)}${String(x.leadTime).padStart(15)}`); console.log(`\nlead time = time queued + cycle time; cycle time = work + wait`); console.log(`average: lead time ${five.leadTime.toFixed(1)}, cycle ${five.cycle.toFixed(1)}, ` + `queued ${five.queued.toFixed(1)} days; wait share ${(100 * five.waitShare).toFixed(1)}%; ` + `throughput ${five.throughput.toFixed(2)} items/day`);
work items: 60, base work: 285 person-days, last arrival: day 40, team 4 (seed 20250715)
WIP limit 5: three metrics on the first eight items (days):
item size queued cycle time of which wait lead time
0 7 1 9 2 10
1 7 0 9 2 9
2 7 0 9 2 9
3 5 0 7 2 7
4 2 0 3 1 3
5 2 2 3 1 5
6 2 4 3 1 7
7 3 3 4 1 7
lead time = time queued + cycle time; cycle time = work + wait
average: lead time 21.9, cycle 6.6, queued 15.3 days; wait share 27.8%; throughput 0.74 items/day
Item six shows in a single line why the metrics have to be kept apart: a two-person-day piece of work finishes in three days once it is pulled into work, but it takes seven days from the day it was requested. Cycle time says the item carries one-third of a day’s wait; lead time says three-quarters of the duration passed before work even began. In the same run, these two metrics can be four times apart from each other.
Scanning the WIP Limit
PM18 — three metrics drive three separate decisions, and the point of the scan is to see which one points to which limit. The block below scans the limit from one to sixteen.
// limit.mjs — reads the scan results flow.mjs wrote; the WIP limit is scanned end to end import { readFileSync } from "node:fs"; const T = JSON.parse(readFileSync("scan.json", "utf8")); const BASE = 285; const table = (header, row, caption) => { const write = (h) => console.log(h.map((c, i) => String(c).padStart(header[i][1])).join("")); if (caption) { console.log(); console.log(caption); } write(header.map((b) => b[0])); row.forEach(write); }; table([["WIP limit", 10], ["total days", 12], ["lead time", 15], ["cycle time", 15], ["queued", 10], ["wait share", 14], ["throughput", 12]], T.map((r) => [r.wip, r.day, r.leadTime.toFixed(1), r.cycle.toFixed(1), r.queued.toFixed(1), `${(100 * r.waitShare).toFixed(1)}%`, r.throughput.toFixed(2)])); table([["WIP limit", 10], ["total capacity", 17], ["base work", 10], ["idle", 11], ["to switching", 14], ["idle share", 12]], T.map((r) => [r.wip, 4 * r.day, BASE, r.idleCapacity.toFixed(1), r.switchingLoss.toFixed(1), `${((100 * r.idleCapacity) / (4 * r.day)).toFixed(1)}%`]), "where the capacity goes (person-days):"); // PM18: three metrics drive three different decisions; each one points to a different limit. const least = (f) => T.reduce((a, b) => (f(b) < f(a) ? b : a)); const peak = least((r) => -r.throughput), last = T[T.length - 1]; console.log(`\nshortest cycle time at limit ${least((r) => r.cycle).wip}, ` + `shortest lead time at limit ${least((r) => r.leadTime).wip}, ` + `highest throughput at limit ${peak.wip} (${peak.throughput.toFixed(2)} items/day)`); console.log(`throughput peaks at limit ${peak.wip}, then falls: ` + `at limit ${last.wip} it is ${last.throughput.toFixed(2)} items/day and ${last.switchingLoss.toFixed(1)} person-days go to switching`); console.log(`lost below the limit: at limit 1, ` + `${((100 * T[0].idleCapacity) / (4 * T[0].day)).toFixed(1)}% of capacity sits idle, ` + `at limit ${peak.wip} it is ${((100 * peak.idleCapacity) / (4 * peak.day)).toFixed(1)}%`);
WIP limit total days lead time cycle time queued wait share throughput
1 285 118.5 4.8 113.8 0.0% 0.21
2 143 51.0 4.8 46.3 0.0% 0.42
3 98 28.4 4.8 23.7 0.0% 0.61
4 72 17.3 4.8 12.5 0.0% 0.83
5 81 21.9 6.6 15.3 27.8% 0.74
6 83 23.8 8.1 15.7 41.2% 0.72
8 89 27.6 11.3 16.3 57.9% 0.67
10 94 31.4 14.6 16.8 67.5% 0.64
12 102 36.7 18.9 17.9 74.8% 0.59
16 123 51.0 29.9 21.1 84.1% 0.49
where the capacity goes (person-days):
WIP limit total capacity base work idle to switching idle share
1 1140 285 855.0 0.0 75.0%
2 572 285 287.0 0.0 50.2%
3 392 285 107.0 0.0 27.3%
4 288 285 3.0 0.0 1.0%
5 324 285 5.0 11.4 1.5%
6 332 285 4.0 23.6 1.2%
8 356 285 7.0 49.2 2.0%
10 376 285 4.0 75.7 1.1%
12 408 285 4.0 109.1 1.0%
16 492 285 5.0 195.9 1.0%
shortest cycle time at limit 1, shortest lead time at limit 4, highest throughput at limit 4 (0.83 items/day)
throughput peaks at limit 4, then falls: at limit 16 it is 0.49 items/day and 195.9 person-days go to switching
lost below the limit: at limit 1, 75.0% of capacity sits idle, at limit 4 it is 1.0%
Which Metric Drives Which Decision
The table’s most instructive row is the first one. When the WIP limit is one, cycle time is shortest at 4.8 days and wait share is exactly zero — the single open item is worked without interruption and without any wait at all. In the same row, lead time is 118.5 days and 75% of capacity sits idle. A flow managed by watching cycle time alone makes the worst possible system look flawless.
When the limit rises to four, four people spread across four items, idle capacity drops from 75% to 1%, throughput rises from 0.21 to 0.83 items/day, and lead time falls from 118.5 to 17.3 days. Up to this point cycle time has not degraded at all (4.8 days), because each item still has a full person assigned to it.
Past a limit of five, a second mechanism kicks in. The moment more than one item falls to each person, cycle time rises from 4.8 to 6.6 days and wait share jumps from zero to 27.8%. At sixteen, cycle time is 29.9 days and wait share is 84.1%: five-sixths of an item’s life passes with no one working on it. Throughput peaks at four and drops to 0.49 at sixteen; 195.9 person-days go to switching. Raising the limit opens more items at once, but finishes none of them any sooner.
The three metrics look at three separate decisions. Cycle time measures the flow inside the work itself and shows where in the process wait accumulates; read alone, it hides everything sitting in the queue. Lead time is the measure of the promise given to demand, and it drives the capacity decision. Throughput says whether new work can be accepted, and the point where it saturates marks the upper end of the limit. In this run, the three point to limits of one, four, and four respectively; reading only one of them means missing two of the three.
Summary
- Lead time, cycle time, and throughput were defined in the same run; lead time is queue wait plus cycle time, and cycle time is work plus wait.
- At limit 1, cycle time is shortest (4.8 days) and wait share is zero, but lead time is 118.5 days and 75% of capacity sits idle — cycle time alone is misleading.
- At limit 4, idle capacity drops to 1%, throughput peaks at 0.83 items/day, and lead time falls to 17.3 days; up to this point cycle time never degrades.
- Above limit 4, wait share climbs from zero to 84.1%, cycle time stretches to 29.9 days, throughput falls to 0.49, and 195.9 person-days go to switching.
- In this run the three metrics point to limits of 1, 4, and 4 respectively; the limit cannot be chosen without reading all three together.
Next Step
Every measurement in this lesson was made on a single team: four people, one backlog, one WIP limit. Once that assumption is removed, a new cost item is born. When a work item requires a change that touches more than one team, a coordination round has to pass between teams, and that round is neither work nor queue wait. The next lesson counts the price of multi-team coordination: as the number of teams grows, how many coordination rounds fall to each dependent work item, what a joint planning round costs, how much delay the coordination layer adds on its own, and whether this cost grows linearly or quadratically with team count. The same run asks a second question — how many rounds are gained by removing which type of dependency.
To keep your progress and take notes, Log in
My notes
Log in to take notes.