A three-week, eight-route field measurement · Chicago
What your average bus frequency isn’t telling you
Three weeks, eight of Chicago’s busiest bus routes, every arrival logged. Turns out “every 10 minutes” and the actual experience of waiting are not the same thing. Here’s what I found, why it happens, and what it costs.
Practicing Economist · July 2026 · a personal project, written in my personal capacity: views mine alone, not any employer’s or the CTA’s
● live-style demo · simulated from measured data · route 22 clark
#22 CLARK2 MIN
#22 CLARK3 MIN
#22 CLARK27 MIN
↑ Two buses nose-to-tail, then a hole. If this board looks like your commute, the next few minutes explain why, and put a price on it.
Waiting at a bus stop can feel like an eternity, even when the sign insists the bus is only a few minutes out. For a while I figured that was just my own impatience talking. Then it kept happening, often enough that I started wondering whether it was actually me, or whether there was something real behind the feeling. So I decided to check. I will admit up front that I ended up spending considerably more evenings and weekends on this than any reasonable person should spend confirming a hunch about a bus schedule.
Here’s the arithmetic that got me hooked in the first place. Picture three buses on a route that’s scheduled to run every ten minutes. In reality, the first two arrive bunched, one minute apart. The third doesn’t show up for another twenty-eight minutes. Average those three gaps: (1 + 1 + 28) ÷ 3 = exactly ten minutes. On paper, and in whatever statistic the agency reports, this route is running perfectly to schedule.
Now stand at the stop instead of looking at the spreadsheet. Almost the entire half hour is that one 28-minute gap: it's 93% of the time between buses. So if you show up at a random moment, more than 9 times in 10 you land inside it, not inside the lucky 1-minute gaps that dragged the average down. Work out the actual expected wait for a random rider in this pattern and you get about 13 minutes, not the 5 minutes you’d expect from a true, even, every-10-minutes service. The average was fine. The experience was more than double what the average implied.
That gap between the reported average and the felt wait is the whole subject of this post. It’s not a rounding error or a fluke of one bad route. It’s a predictable consequence of how uneven the spacing is, and it’s something you can actually measure, route by route, city by city. I measured it for Chicago.
To go looking for it rather than just feel it, I picked eight of Chicago’s busiest bus routes (Halsted, Ashland, Clark, Broadway, Western, Belmont, 79th and Sheridan), spread across the North, West and South Sides. Busy routes are where bunching does the most damage and where the most riders would actually feel it, so that’s where I pointed the instrument.
How it was measured (the fun part)
This entire project ran on a 2014 MacBook Air a friend handed down to me: a laptop that started the week locked with a forgotten password. Wiped it, installed Python, and put it to work:
Collection. A ~60-line Python script polls the CTA Bus Tracker API’s getvehicles endpoint for all eight routes in a single request, every 30 seconds, and appends every bus’s position (including pdist, its distance traveled along the route in feet) to a SQLite database. The CTA refreshes positions about once a minute, which sets the effective resolution.
Headways. For each route and direction I take the route’s midpoint and record the moment each bus crosses it. The gaps between consecutive crossings are the realized headways: their mean and CV, computed within each hour of the day, are the engine of the cost figures further down. I also measured the same thing at six points along each run (10% through 85% of the way), which is what later lets me separate “born at the terminal” from “bred on the street.”
The benchmark. The CTA publishes its full schedule as GTFS data. Differencing the scheduled departures at each route’s midpoint stop gives the promised gap, hour by hour, and computing excess wait within hours means intentional frequency changes (rush vs evening) are never mistaken for unreliability.
Ridership. Average boardings per route and day-type from the City of Chicago open data portal, averaged over the latest twelve months of reports.
Total cost of the apparatus: $0 and one aging laptop, no special hardware. Everything here is reproducible from public data by anyone, which is either an indictment or an invitation, depending on your mood.
8
routes tracked
21 days
continuous logging
Interactive · drawn from the measured distributions
Stand at the stop yourself
Pick a route, a day, and where along the route you’re catching it. I’ll simulate a full day of arrivals using that route’s actual measured gap and spacing pattern, drop you at the stop at a random moment, and see how long you wait. Do it a few times: the average of your waits converges to the real tax.
● simulated board
#22 CLARK— MIN
#22 CLARK— MIN
Press the button. You’re walking to the stop at a random time…
Where you board matters because spacing gets worse the further a bus has traveled from the garage. More on exactly why below. Boarding “near the terminal” draws on the same buses that are, on average, still fairly evenly spaced; “near the end” draws on buses that have had the whole route to drift apart.
One number tells you how bad it is
Transit researchers have a name for the wobbliness in that arithmetic example: the coefficient of variation (CV) of the gaps between buses, the standard deviation of the gaps divided by their mean. My 1-1-28 example has a CV of about 1.3. A route where every bus comes exactly 10 minutes apart has a CV of 0. Real service sits somewhere in between, and where it sits determines how much of the “average is fine” story is actually true. Watch what the same “bus every 10 minutes” looks like at different CVs. Each tick below is a bus arriving:
That 0.3 line isn’t mine. The Transit Capacity and Quality of Service Manual (the Transportation Research Board reference that US agencies actually use) grades frequent bus service on a letter scale by exactly this statistic, with CV below 0.3 counting as excellent reliability. At CV ≈ 1.0 (not far from my illustrative example), the standard deviation of the gap equals the gap itself, and a “10-minute bus” might come in 2 minutes or 25 with no way to know which in advance.
So that’s your benchmark before any real data: 0.3 is reliable, and CV near 1 means the schedule is closer to a suggestion than a promise. Here is where Chicago actually falls, pooled across three weeks of measurement: the eight routes I tracked ran between CV 0.59 and 0.76 on an ordinary weekday: every single one at least double the reliability benchmark, and got markedly worse on weekends, with the 9 Ashland reaching CV 1.11 on an ordinary Saturday, past the territory of my made-up worst-case example.
Why buses clump (it’s physics, not laziness)
Bunching is a feedback loop, and once you see it you can’t unsee it. A bus falls slightly behind: one red light, one wheelchair ramp. Now it has a bigger gap in front of it, which means more passengers have accumulated at every stop ahead. More passengers means longer boarding, which means it falls further behind. Meanwhile the bus behind finds emptier stops, boards faster, speeds up, and closes in. Even spacing is unstable: any wobble amplifies until buses travel in pairs with a hole behind them.
Interactive · a tiny physics model
Watch even service fall apart
Six buses on a loop, perfectly spaced. Turn on real-world friction (the more passengers waiting ahead of a bus, the slower it moves) and watch the clumps form on their own. No bad drivers required.
friction: off, spacing holds
Where is the bunching actually born?
The toy proves the loop can happen. But on Chicago’s actual streets, which is it: do buses leave the terminal already clumped (a dispatch problem), or leave evenly and decay as they travel (the feedback loop)? Three weeks of data can answer that: I measured headway CV not just at each route’s midpoint but at six points along every run (10%, 25%, 40%, 55%, 70% and 85% of the way). Reading across is reading the biography of the bunching. It’s the same six points the simulator above let you stand at.
Interactive · three weeks of data, both directions averaged
The spacing decay curve, route by route
Tap a route. Height = CV of the gaps at that point in the run. The green line is the 0.3 “reliable” benchmark. Notice that nothing ever gets near it, at any point, on any route.
Two things are true at once, and both matter.
First: the buses leave the garage already uneven. At just 10% into the run (barely out of the terminal), every route is already at CV 0.5 or worse, far past the reliability line. Most of the spacing tax is loaded onto the bus before it has met its first traffic jam.
Second: the street then reliably makes it worse. All sixteen route-directions I measured (sixteen out of sixteen, no exceptions) show CV climbing steadily from the start of the run to the end. The 22 Clark decays from 0.63 to 0.79; the 151 Sheridan from 0.62 to 0.81. That relentless, same-shaped climb on every route is the fingerprint of the boarding feedback loop, not random traffic: congestion throws the dice, but the loop is what compounds them. In excess-wait terms, the en-route decay adds roughly another third to a half on top of what dispatch already baked in.
The 8 Halsted is the quiet star: the flattest curve of the eight. Whatever its schedulers and supervisors are doing differently, it’s working. It’s the natural control case for everything below.
One more thing the data volunteered: buses joining mid-route (garage pull-outs, gap-fillers) are real but rare, roughly 85–90% of vehicle-days begin at a terminal, and the remainder first appear somewhere later along the run. That matters for how this measurement works: a bus that starts before my midpoint still gets counted normally once it crosses it, same as any full-route bus. A bus that first appears after the midpoint is invisible to this method entirely: there's no earlier sighting to pair with a later one, so it never registers as a crossing at all. In practice this trims a modest slice of buses from the headway count on each route, not a large one, but it’s a real, direction-specific blind spot rather than random noise.
Now that you have a benchmark, take a guess
Given those CVs, what do you think uneven bus spacing costs Chicago riders per year in wasted waiting time across just these eight routes (valuing time at a conservative $10/hour)?
$0.0 million / year
Eight routes: the numbers
Tap a route. The bars show measured CV by day type against the green 0.3 reliability line; the figures below it come straight from the analysis. “Excess wait” is the extra minutes an average boarding spends waiting because spacing is uneven, beyond what the same frequency would cost if buses were perfectly even.
Weekend figures unavailable for the 36 and 151 (added to the tracker midway). Weekday excess-wait for the 9, 77 and 79 should be read as upper bounds. See the caveats section.
The full weekday table (14 pooled weekdays)
Route
CV
Real gap
Sched gap
Excess wait
Boardings
$/year
Real vs scheduled gap is the quiet bombshell of this table: they nearly match everywhere. The buses are out there, running at roughly the promised frequency. The problem is spacing. That’s exactly the thing the average conceals.
Putting a price on the wait
For frequent service, riders don’t time their arrival to a timetable. They just show up. Queueing theory gives a clean result for that world: the average wait isn’t half the average gap, it’s
E[wait] = (average gap / 2) × (1 + CV²)
Perfectly even service (CV = 0) costs you half a gap. Everything above that, the (gap/2) × CV² part, is pure spacing tax: waiting that exists only because the buses clump. Multiply that per-boarding tax by each route’s actual ridership (from the City of Chicago data portal), and by a value of time, and you get dollars.
At $10/hour (about a third of the median Chicago wage, a deliberately conservative floor), the measurements imply:
What is an hour of your time worth? Drag to re-price the whole thing.
$10/hr
$10.7M
weekdays / yr (×255)
$4.0M
weekends / yr (Sat×52 + Sun×59)
$14.7M
combined / yr · 8 routes
1.47M
rider-hours / yr (doesn’t move)
Whatever you set the slider to, the 1.47 million rider-hours stay fixed: that’s time actually spent standing at stops, not a modeling choice. It’s the equivalent of about 168 person-years of waiting, every year, on eight routes, not waiting for buses to exist, but waiting for buses that exist to be evenly spaced.
And resist the urge to multiply up: these are eight of the busiest routes in a network of ~125, and busy routes both bunch more and carry more people. The honest claim is “$14M on eight routes alone”. The systemwide number is bigger, but I didn’t measure it.
Why 255 / 52 / 59: the CTA runs three schedule types (weekday, Saturday, and Sunday), and Sunday service also covers major holidays. A year works out to roughly 255 weekdays, 52 Saturdays, and 52 Sundays plus about 7 holidays running Sunday service, which rounds to 59. All three add up to the ~365 days in a year, with each day counted exactly once.
The strangest day in the data
The worst single day I measured wasn’t a rush-hour weekday. It was Friday, July 3rd, the observed holiday, when the CTA runs its Sunday schedule. Riders lost roughly 4,100 hours to uneven spacing that day, rivaling a full weekday despite far fewer buses: the 79th hit CV 1.09, among the highest single-day figures I recorded anywhere in the project. (The single highest CV in the full three-week dataset turned out to belong to the 9 Ashland, at 1.11, pooled across three ordinary Saturdays, see the route explorer above. July 3rd remains the worst single day, not the worst single number.) Holiday traffic plus a skeleton schedule is apparently a strong bunching combination.
July 4th itself was the opposite: the calmest, cheapest day of the week (~2,200 hours lost). The buses, like everyone else, had the day off from being stuck behind each other.
So what could the CTA actually do?
Here’s where the decay curves and the dollar figures stop being trivia and start being a to-do list, because each part of the curve points at a different, and differently priced, fix:
1 · Fix the launch: terminal headway disciplineThe curves say most of the problem exists by the 10% mark, so the cheapest lever is at the garage door: don’t release a bus until the gap behind its leader is right. No new buses, no capital, pure operations. If the 8 Halsted’s flat curve reflects tighter dispatch, the playbook already exists in-house.
2 · Break the loop: holding points and faster boardingThe steady en-route climb is the feedback loop compounding, and it has known antidotes: brief headway-based holds at one or two mid-route control points (a bus that’s too close behind its leader waits 30–90 seconds: on-board riders pay a small delay so waiting riders avoid a big one), and anything that shrinks boarding time itself (all-door boarding, off-board fares), which lowers the loop’s gain directly. Bus lanes and signal priority reduce the random shocks that start the spiral.
3 · Plug the holes: smarter mid-route insertionStarting a fresh bus in the middle of a developing gap, or short-turning one to fill a hole in the opposite direction, is the surgical fix for the worst moments. The data shows the CTA does this sparingly today; the same real-time feed I used to measure the problem could target exactly where the gap is opening.
This is mostly not about driver discretion. Even spacing is physically unstable on its own: a driver's individual choices can nudge things at the margin, but the pattern doesn't require anyone doing anything wrong to emerge. The real question is which stabilizers get installed, and the curves above say where: first at the terminal, then at the choke points where the climb is steepest.
Did every scheduled bus even show up?
The natural follow-up question, and the one where I have to be most careful. I can compare the number of bus passings I observed at each route’s midpoint against the number the schedule implies. Pooled across all three weeks of data (14 weekdays, 3 Saturdays, 4 Sundays), here’s that ratio:
Route
Weekday
Saturday
Sunday
#8 HALSTED
75%
98%
92%
#9 ASHLAND
47%*
79%
78%
#22 CLARK
82%
87%
77%
#49 WESTERN
91%
95%
93%
#77 BELMONT
75%
90%
84%
#79 79TH
61%
77%
74%
Read this as the share of scheduled service my instrument captured: a floor on what ran, not a verdict on what didn’t. The shortfall is provably contaminated by my own measurement: my laptop’s collection holes eat passings, my detector needs to see a bus on both sides of the line to count it, and (the big one) on weekdays several routes run extra short-turn buses over their busiest segments on separate service patterns that my full-route measurement only partly sees. That asterisk on the 9 is exactly this: its weekday service splits across a dozen patterns, and my method captures about half of them. The same route runs mostly one or two patterns on weekends, which is why its weekend capture rate looks completely different (79%, 78%) from its weekday one. Says everything about my instrument and route structure, nothing about missing Ashland buses.
Worth noting: a single day of measurement would have understated this table, especially on weekends: a lone Saturday would have shown Route 8’s capture rate around 92%; pooled across three ordinary Saturdays it’s 98%. Same direction as the CV figures earlier: more days made the numbers more honest, not more alarming.
So this table is deliberately not headlined “a quarter of scheduled buses never come.” The honest claim is narrower: I can verify that at least this much of the schedule physically ran past the midpoint, and the true figure is higher. Answering the question properly requires matching each observed bus to its scheduled trip ID, which the CTA feed supports, my collector didn’t record, and part two of this project will. If the gap survives trip-level matching, that becomes a story. Until then, the strong, clean evidence stays where it’s been all along: spacing.
What this does not say
This is one person, one laptop, three weeks. The pattern is unambiguous; the precise figures deserve their error bars listed out loud:
Three weeks of dataJune 26 – July 19, 2026. The spacing decay curves pool all collected days; the dollar figures pool 14 ordinary weekdays, 3 ordinary Saturdays and 4 ordinary Sundays (a holiday-schedule Friday and July 4th itself are shown separately, excluded from the totals). Still a careful estimate, not an audit.
$10/hour is an assumptionDeliberately conservative, about a third of the median Chicago wage. That’s why the slider exists: pick your own and the hours don’t change.
Upper bounds on three routesOn weekdays the 9, 77 and 79 run extra short-turn buses over their busiest segments that my full-route measurement only partially captures. Riders there likely wait somewhat less than my figures imply. (The 8, 22, 36, 49, 151 and all weekend figures don’t have this issue.)
Two assumptions baked into the cost mathThe wait-time formula assumes riders show up at random moments within each hour, which is standard for frequent service (nobody times a 10-minute bus to the timetable), but it also uses one measurement point per route, the midpoint, as a stand-in for the whole route. The decay-curve section above shows CV actually gets worse the further a bus has traveled, so a boarding near the terminal experiences a better spacing than the midpoint number, and one near the end experiences worse. The midpoint is a reasonable average if riders board roughly evenly along the route; if ridership actually clusters more at one end, the true systemwide figure could run somewhat higher or lower than what’s reported here. I didn’t have per-stop boarding data to check this.
Known gaps excludedMy laptop slept through parts of three separate mornings before I fully learned my lesson about caffeinate and power cords. Those windows are excluded from every calculation, and marked in the code.
No claim about missing busesMy bus counts run below scheduled counts, but the shortfall matches my measurement holes, not vanished service. This post makes no claim that the CTA is skipping runs: the evidence here is about spacing, where it is very strong.
Busy routes onlyEight high-ridership routes. Nothing here extrapolates to the other ~117.
Chicago onlyEvery number in this post is measured from Chicago data. I have not measured any other city, and I’m not assuming these figures or even this CV range carry over to yours.
If you want to check whether this holds where you live
Everything above is a Chicago finding, measured from Chicago data, and I’m not assuming it carries over anywhere else. But the method isn’t Chicago-specific, and if you’re curious whether your own city’s buses behave the same way, here’s what it took.
What you need. Two things, both usually free. A realtime vehicle feed: many agencies publish GTFS-Realtime, a common standard, and some run their own (Chicago’s Bus Tracker, New York’s MTA Bus Time on the SIRI standard). And a static schedule: GTFS, a zip of CSVs that most agencies of any size publish, which gives you the promised headway to measure against.
The recipe, in four steps. Poll the realtime feed every 30–60 seconds and append each bus’s position to a database: that’s the only part that takes patience, because you need days of it. Pick a point along each route and record the moment each bus crosses it; the gaps between consecutive crossings are your realized headways. Take the mean and the coefficient of variation within each hour, so the schedule’s own frequency changes never masquerade as unreliability. Then multiply the excess wait by ridership and a value of time. Most agencies publish boardings by route.
The whole analysis is a few hundred lines of Python and no special hardware. If you run it somewhere else, I’d genuinely like to see what you find, including whether it looks nothing like Chicago. I don’t have a prior on that either way.
Two things I’d do differently if starting over: record each bus’s scheduled trip ID from the start (it makes “did this run actually operate?” answerable), and detect crossings at a fixed lat/long line rather than a route-relative distance, so branch and short-turn services are counted automatically.