Flight search · engine notes

A Million Flights, No Server

There is no free flight-pricing API left to call. So I built the search to run where the data already is: a columnar binary file in the browser, filtered, sorted and charted without a single network request while you use it. These are the measurements that shaped it, including the ones that proved me wrong.

8.63msFull frame, one million rows
14.2Bytes per offer on disk
0Requests while searching
1,200Real markets, real fares
17 July 2026

The constraint came first

Amadeus decommissioned its self-service portal on 17 July 2026, disabled existing keys and archived its SDKs the same day. What remains for live fares is enterprise contracts and paid scrapers. The free tier that every side project used is gone.

That settles the architecture before any code gets written. If fare data can only be acquired in bulk and offline, then querying a provider per keystroke was never the design. The search has to happen where the data already sits, which means shipping the data to the client and doing the work there.

Everything below follows from that one inversion. The interesting problems stop being about network latency and start being about how fast you can scan a few hundred megabytes of RAM inside a 16ms frame.

14.2 bytes/row

A format you can read without parsing

Offers are stored in a slab: one binary buffer, each field its own typed array laid end to end, with a small JSON blob at the tail describing the layout. Strings live in dictionaries and rows reference them by integer.

hdr depMin arrMin durMin priceCents stops carrier origin · dest metadata + dictionaries one ArrayBuffer 14.2 bytes a row a price filter reads 4MB and never touches the other 15
Columns sit contiguously, so a filter streams exactly the field it tests. The same million offers as row-oriented JSON would be many times larger and would have to be parsed into objects before the first comparison. Two of the eight columns are not stored at all: every row in a per-origin file departs the same airport, and arrival is always departure plus duration, so both are rebuilt on arrival rather than sent. That sum is a clock at the origin, so the destination's time zone is added back when the row is painted — on the forty rows you can see, never on the million you cannot.

Decoding is the part that pays off. The metadata names each column's byte offset, so the reader constructs typed-array views over the existing buffer and stops. No per-row object is allocated anywhere. Decoding a million offers takes 0.21ms, which is not a parsing cost so much as the absence of one.

The size surprise. I assumed compression would shrink this a lot. Brotli at maximum quality gets 1.9×, not the 3× I expected: packed binary columns are high-entropy and there is little left to find. A million offers is 9.2MB on the wire. That number, not the raw size, is what decided how it ships.
9.2MB → 172KB

How a million offers reach you

Two changes take the download from most of a feature film to something you would not notice, and neither touches the engine.

Sort before encoding. Rows arrive in whatever order they were generated, which is the worst case for a compressor. Sorting by origin, destination and departure puts equal values next to each other and leaves small deltas behind them. The corpus goes from 9.2MB compressed to 5.7MB for the cost of a sort nobody waits for, since it happens at build time.

Publish one file per origin. Nobody searches every airport at once. The corpus is partitioned into 90 shards, and a visitor fetches the one covering their origin — 154KB on average, and the page opens on the airport nearest them rather than a fixed one. Picking a different origin fetches a different file. A multi-city trip fetches the shard each leg departs from and concatenates them, which the kernels never notice.

What actually crosses the wire, measured on the deployed site
RequestRawTransferred
Manifest (airports, airlines, shard index)11.9KB4.1KB
Smallest shard — Orlando Sanford6.9KB3.7KB
A typical first visit — Seattle, 26,386 offers363KB172KB
Busiest shard — Orlando, 60,612 offers831KB384KB
One global file instead19.1MB9.2MB

The files are static and immutable, so a CDN serves them and there is still nothing on the request path. Opened straight from disk there is nothing to fetch at all, and the page generates its offers in-process instead — the engine cannot tell which happened.

16.4ms → 8.1ms

Filters that stop re-reading the same rows

The first design kept a bitset per predicate and intersected them. Each filter scanned all million rows, so four filters scanned four million. That measured 16.4ms, comfortably over a frame, and it would have killed the whole premise.

The fix is a selection vector. The first predicate scans every row and writes the survivors into a Uint32Array. Every predicate after it only re-checks what is left. With a filter that keeps a quarter of the rows, the second pass is a quarter of the work and the third is a sixteenth.

The inner loops write unconditionally and advance the cursor by zero or one, so there is no branch for the CPU to mispredict:

One million rows, Apple Silicon, Node 24
Operationp50p95
One filter, slider drag2.35ms2.51ms
Four filters, typical query6.91ms7.03ms
Price histogram, 48 bins0.60ms0.61ms
Date grid, 60 day buckets0.64ms0.68ms
Window of 40 rows0.52ms0.54ms
Full frame, all of the above8.63ms8.93ms

Sorting is precomputed rather than repeated. A stable four-pass radix argsort builds one permutation per sort key at load, 11 to 18ms each, and switching between cheapest, fastest and earliest afterwards is just picking a different permutation.

0.02ms

Multi-city, without building the itineraries

A three-leg trip across 100,000 offers per leg has 1015 combinations. Materialising them is not a performance problem to optimise, it is a thing you must never do.

You do not have to. The cheapest itinerary is necessarily the cheapest offer on every leg. And every itinerary after that differs from one already found by a single step up on a single leg. So the candidates worth examining are always a thin frontier around what you have already emitted, and a heap over that frontier yields trips in price order.

leg 2, cheapest first ↓ leg 1, cheapest first → $393 $401 $404 frontier: the only candidates examined everything else is never visited
Filled marks are itineraries already emitted, in price order. Ringed marks are the frontier the heap holds. Forty itineraries cost forty pops, whatever the size of the grid.

Combining forty itineraries measures 0.02ms. The legs' own filtered queries are the entire cost of a multi-city search, at 7.30ms for three legs.

I expected this to change the file format. I said it would need a second slab version with a legs table. It needed nothing. An itinerary is a composition produced at query time, not a kind of row, and once that was clear the feature added one kernel and no migration.
161ms → 2ms

What the worker actually buys

Moving the engine into a Web Worker is the obvious architecture, but at the size the demo ships it is close to a wash, and saying so is more useful than claiming a win. The page sends filters and a window; it receives counts, charts and a packed array of only the rows about to be painted.

Dragging the price slider, one query per frame
 Main threadWorker
500k — blocked per move4.6ms2.3ms
500k — latency to redraw4.6ms13.9ms
3M — blocked per query161ms~2ms
3M — latency to redraw161ms134ms

At 500,000 rows the worker halves the blocking and costs about 9ms of added latency to do it. At three million it is the difference between a page that responds and one frozen for a sixth of a second on every pointer move. Notice that the two main-thread numbers are identical at 3M: when the engine runs inline, the engine time is the block. The demo keeps the worker because the architecture should hold at the sizes the design exists for, and exposes ?engine=inline so anyone can check the claim.

1.7GB → 67KB

Fares that someone actually paid

Generated prices would have undermined everything else, so the fares come from the US DOT's Airline Origin and Destination Survey: a 10% sample of tickets sold by US reporting carriers, published quarterly, public domain, no account and no rate limit.

The harvester streams 1.7GB in two passes, one to rank 65,100 directional markets by traffic and one to build fare curves for the busiest 1,200. Each market keeps its observed spread as nine deciles rather than an average, because the gap between a cheap seat and an expensive one on the same route is the interesting part. The result is 67KB.

Harvested markets, 2025 Q1 — nothing here is modelled
MarketMedianp90Carrier share
LAX → JFK$293$1,103DL 44% · B6 35% · AA 19%
LGA → ORD$153$308UA 33% · AA 27% · YX 14%
LAX → HNL$198$408HA 38% · UA 22% · DL 16%

That table is the validation. JetBlue and Delta splitting the JFK transcon, a p90 up at $1,103 where the premium cabins sit, Hawaiian leading Los Angeles to Honolulu, Republic flying regional for the majors out of LaGuardia. None of it was put there.

Departure times are a different matter and the README says so plainly. DB1B records what was paid and how far the passenger flew, never when they left, so schedules are generated from distance with the connection rate taken from each market's own observed share. Fares, routes, carriers and carrier mix are data. Clocks are not.

five corrections

What measurement kept telling me

The useful pattern in this project was how often a confident assumption turned out to be wrong in a way that only a number could reveal.

I assumed Scrolling to the end of 500,000 rows would hit a performance cliff. It measured 0.63ms at offset 499,000. A tight loop over a typed array is simply fast. I did not optimise it.
I assumed The browser caps element height around 33.5 million pixels. It is 224, measured at 16,777,214. My spacer silently shortened and the last 53% of results were unreachable. The fix maps scroll position using the DOM's real range instead of a constant I believed.
I assumed Compression would do most of the work on the wire. Brotli got 1.9×. Packed columns have little redundancy left, which changes the delivery plan from one file to per-origin shards.
I assumed Multi-city would need a second version of the file format. It needed none. Itineraries compose at query time.
My benchmark said Multi-city search costs about 5.5ms. The live page showed 58ms. The benchmark used broad legs; a real leg is one route on one day, around fifty rows out of half a million, and finding its cheapest offers walked the entire global price order. Sorting the narrow selection directly brought it to 2.94ms. The benchmark had been measuring the wrong shape of query and hiding a twentyfold regression.

The last one is the one I would keep. A benchmark that measures a plausible-looking case rather than the real one does not merely fail to help, it actively reassures you while the product is slow.

125 tests

Where it ended up

About 2,300 lines of dependency-free TypeScript, run through Node directly rather than compiled. The demo is a single 179KB HTML file with the engine inlined, because ES module imports are blocked on file:// origins and the page should open from disk. That file is generated from the same source the tests run against, and a staleness check fails the build if the two drift.

It fetches the shard for the airport nearest you, then filters and sorts and charts those offers locally, builds multi-city itineraries, and encodes every view into a shareable link. After the file lands, it makes no network requests at all.

Try it on real fares

Half a million offers over 1,200 US markets, filtered and sorted in the browser. Drag the price slider and watch the distribution redraw under your cursor.

Open the search →

Built by Param Mehta. The engine, the harvester and this write-up are in the repository; the search runs the real thing on real fares.