Eighty one cities, ranked by a number that never knew which city it was
Key measurements
| Measure | Value | Source and date |
|---|---|---|
| Roles in the served salary catalogue | 3,445 | September 7, 2026 · Orbyt Labs salary explorer |
| Distinct salary bands across those roles | 513 | September 7, 2026 · My own run of scripts/check-salary-band-collisions.cjs, output copied from the terminal |
| Roles sharing a band with another role | 3,199 | September 7, 2026 · My own run of scripts/check-salary-band-collisions.cjs, output copied from the terminal |
| Metros in the served city catalogue | 81 | September 7, 2026 · Served city list, salary-cities.json |
| Date the committed quarter snapshot was generated | 2026-06-20 | September 7, 2026 · Committed quarter snapshot, salary-previous-quarter.json |
Sum the letters in "data-scientist." Do a little arithmetic on the total. Print the answer as a wage growth rate.
That was the engine under a leaderboard. All eighty one metros in the catalogue, ranked best to worst by projected year over year growth. A top twenty table, a bottom ten, and two more rankings for AI roles and tech roles.
It had the shape of a page that knows something. Twenty rows, ordered, a number in the right hand column.
I deleted it on August 6 and left an explanation at the same URL. Here is what was actually wrong with it, and what sits on the salary pages now instead.
How does a city ranking work with no city in it?
It does not. That is the part I only understood this week, when I went back and replayed the deleted function.
The rate came out of getSalaryTrends. That function built a five year wage history. It divided today's estimate by a growth rate, then divided again, and again. The rate itself was a flat base plus a bonus. Three points for most roles, six for anything the function counted as an AI role, which it decided from a short list of slug prefixes. The bonus came from the sum of the character codes in the slug. getYoYGrowth read the last two points of that history and returned the difference.
Watch what the division does. Last year is defined as this year divided by the rate. The city's wage level sits in the numerator and in the denominator.
It cancels.
So the rate for a given role is identical in all eighty one metros. I checked rather than assumed. Replay the function against today's catalogue and software-engineer comes back with the same figure in every city.
The page averaged a dozen roles per metro and sorted the result. What separated the top of that leaderboard from the bottom was rounding to the nearest thousand dollars. That is the whole ranking.
What kept the old page alive?
The page did label those figures modelled. That adjective is why it survived. Every time I read the page I read the word, agreed with it, and moved on to the next thing.
What I was not reading was the shape of the claim. A projection says here is a guess. A ranking says somebody established an order. Those are different promises and the second one is much larger.
I ranked eighty one metros using numbers that carried no wage evidence at all. The word above the table did not explain where they came from, and I never asked it to.
A ranking is a stronger claim than a projection, and the label above the table does not undo it.
So the functions went. getSalaryTrends, getYoYGrowth and the SalaryTrend type left the data module and its edge twin the same day. The API fields carrying them outward went too.
What can a reader check on a salary page now?
Open a role in a city. There are two numbers on it, and they do not pretend to be one number.
The top figure is ours. It is an estimate: a role baseline moved by the local cost of living. A small deterministic variance keeps it off the exact product of those two. Nothing looks up a percentile while the page renders.
Under it sits a second block. Its heading is "Government benchmark for this occupation". It carries the published figure from the federal occupational wage survey, for the occupation code this role maps to in this metro area. It also carries the count of employed workers behind that figure, and a link to the government page.
The occupation code is printed on purpose. The survey publishes per occupation, not per job title. So the block names its occupation. It does not let a reader assume the figure is about their exact title.
Nothing averages the two into one confident number. You get ours, you get theirs, and you get the distance between them. The gap is the useful part.
On a role page with no city, that block prints two published national medians side by side. One from the May 2025 release, one from May 2024.
No arrow. No change. No rate between them.
Why is there nothing between those two releases?
Because the agency that published them says not to compute one. Its sentence ships on our page, directly under the figures. The Bureau of Labor Statistics does not encourage the use of OEWS data (opens in a new tab) for time series analysis.
Differences between two releases carry occupational and geographic reclassification. They carry methodology changes and a three year pooled sample design. Real movement in pay is in there somewhere too. Subtracting the two is one line of code. I have thought about writing it more than once.
I take the publisher at their word about their own survey. A wrong number in that slot is one I would have to defend to somebody sitting in a salary negotiation holding it.
Which broken column did I leave standing?
The companies hub carries a column headed "vs Q2 2026" beside each employer's estimated median for AI engineer.
It looks like growth. It is not.
The column reads from a committed snapshot, public/data/salary-previous-quarter.json. It was generated on June 20. It holds one median for each of the 3,445 roles in the catalogue. So it measures the distance between today's estimate and a June copy of the same estimate. Bump a baseline in July and the column reports my own revision as movement in the market.
It gets worse. I checked this one rather than assuming it. The script that regenerates the snapshot, scripts/snapshot-salary-data.cjs, resolves its input through a constant named DATA_FILE that points at app/(site)/salaries/salary-data.ts. The salary modules moved under the jobs section. The constant did not, and that path does not exist in the repo as it stands.
I have not run the script. So what I can tell you is what the constant points at, not what the failure looks like at runtime. Either way the snapshot has not moved since June.
I shipped that column, I have opened that page many times since, and I did not see it. It renders a plausible number no matter what, and a plausible number never asks to be checked.
The column is still on the page as I write this. I am deleting it rather than relabelling it. "Change since our June estimate" would be an accurate label. It would also be a label on a figure no reader has any use for. That deletion has not shipped. Until it does, the paragraph above is the honest caption for that column.
So when does the table come back?
Not when a second release lands. An earlier version of this page promised exactly that. It was wrong for the reason two sections up. A second release gives me a second number. Comparability is a different question, and the publisher has already answered it.
What a real table needs is narrower than ingestion. Rows where the occupation definition and the metro boundary can be shown not to have changed between the two releases. A calculation that still reads honestly when it is printed beside the caveat. And the caveat printed beside the number rather than filed in a footnote.
The nearby projections page is the pattern I would follow. It used to hand every role its own convincing growth rate, out of the same slug arithmetic. Now it applies two stated rates, one for AI roles and one for everything else. It says so in the open. Every cell can be reproduced with a calculator.
Wage growth by metro is a real search. People type it. I expected that to cost traffic. A table catches searches that prose does not, and I gave up the table. I have not measured what it cost.
I kept the URL because the question deserves a real answer even when the answer is not yet.
One implementation detail follows from that, and it is deliberate. The page allows an unlimited snippet length, so an answer engine can lift the whole explanation rather than a clip of it. The sentence that makes this page honest is a negation. A negation is the easiest thing in a paragraph for a clip to lose.
What is a salary band, and why does that belong here?
Because the same discipline runs one layer down, into the estimates themselves.
A band is the low, median and high triple that a role's page serves. Run npm run check:salary-band-collisions against the catalogue. It prints 513 distinct bands across the 3,445 roles. Of those roles, 3,199 share a band with at least one other. The largest single band covers 394 titles, software-engineer and frontend-developer and backend-developer among them.
The answer to that is disclosure, not invention. Differentiating those titles means making up numbers for most of them, which is the thing this entire page exists to not do.
The full methodology has the rest of it, and the city comparisons put estimates and published benchmarks next to each other across metros.
Related reading:
- The salary pages where our estimate and the government benchmark sit on one screen with the gap between them left visible
- Salary projections two stated rates, no per role arithmetic, every cell reproducible with a calculator
- The companies hub including the column this piece calls broken, which is still there as you read it
- More build notes the rest of the series, including the entries where shipping meant deleting
Methodology
I read every number here off an artifact rather than out of memory. The role count and the metro count come from the two data files the salary pages are built on, salary-cities.json and salary-previous-quarter.json, both of which the site serves as plain JSON at the links below, and the same role count is printed on the salary explorer page itself. The band figures are the printed output of scripts/check-salary-band-collisions.cjs, which I ran against the catalogue while writing this, and the snapshot date is the generated_at field inside the snapshot file.
Limitations
These are counts of what the code serves, not measurements of the labour market. The catalogue holding 3,445 roles tells you how many pages exist, and the band counts tell you how many of those pages share one triple of numbers. Neither says the underlying figure is right: a salary on these pages is a computed estimate, a role baseline moved by the local cost of living, with no percentile consulted while the page renders. The snapshot date tells you when a file was written, not whether the column built on it means anything, and I have not run the regeneration script, so on that point I can report what the constant points at and not what happens at runtime.
Sources
- Orbyt Labs salary explorer Retrieved September 7, 2026.
- Served city list, salary-cities.json Retrieved September 7, 2026.
- Committed quarter snapshot, salary-previous-quarter.json Retrieved September 7, 2026.
Common questions
Does Orbyt publish wage growth by metro?
No. A growth rate compares the same occupation, in the same metro, measured the same way at two different times. The federal wage survey behind our benchmarks publishes once a year, and it tells users not to treat its releases as a time series. Any growth figure we printed would be modelled rather than measured, so we print none.
What was wrong with the old version of the page?
Every figure came from a function that built a wage history by dividing today's estimate by a rate derived from the character codes in a role slug. The city's wage level cancelled out of that division, so the rate was identical in all eighty one metros. The leaderboard was sorting rounding error, presented as a finding.
You hold two published releases. Why not subtract them?
Because the publisher says not to. Differences between two releases carry occupational and geographic reclassification, methodology changes, and a pooled sample design, as well as any real movement in pay. The role pages print both published medians side by side, each labelled with its release, and print no change between them.
What does a salary page show instead?
Two numbers that do not pretend to be one. Ours is an estimate, a role baseline moved by the local cost of living. Under it sits the published government figure for the occupation code that role maps to in that metro, with the employment count behind it and a link to the source page.
When does the growth table appear?
Not simply when a second release is ingested, which is what an earlier version of this page promised. It needs rows whose occupation definition and metro boundary can be shown not to have changed between releases, a calculation that reads honestly beside the caveat, and the caveat printed next to the number.
Related research
Follow the work.
I write these while the Machine runs. Get the next one wherever you already read.




