Flight search · engine notes
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.
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.
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.
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.
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.
| Request | Raw | Transferred |
|---|---|---|
| Manifest (airports, airlines, shard index) | 11.9KB | 4.1KB |
| Smallest shard — Orlando Sanford | 6.9KB | 3.7KB |
| A typical first visit — Seattle, 26,386 offers | 363KB | 172KB |
| Busiest shard — Orlando, 60,612 offers | 831KB | 384KB |
| One global file instead | 19.1MB | 9.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.
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:
| Operation | p50 | p95 |
|---|---|---|
| One filter, slider drag | 2.35ms | 2.51ms |
| Four filters, typical query | 6.91ms | 7.03ms |
| Price histogram, 48 bins | 0.60ms | 0.61ms |
| Date grid, 60 day buckets | 0.64ms | 0.68ms |
| Window of 40 rows | 0.52ms | 0.54ms |
| Full frame, all of the above | 8.63ms | 8.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.
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.
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.
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.
| Main thread | Worker | |
|---|---|---|
| 500k — blocked per move | 4.6ms | 2.3ms |
| 500k — latency to redraw | 4.6ms | 13.9ms |
| 3M — blocked per query | 161ms | ~2ms |
| 3M — latency to redraw | 161ms | 134ms |
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.
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.
| Market | Median | p90 | Carrier share |
|---|---|---|---|
| LAX → JFK | $293 | $1,103 | DL 44% · B6 35% · AA 19% |
| LGA → ORD | $153 | $308 | UA 33% · AA 27% · YX 14% |
| LAX → HNL | $198 | $408 | HA 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.
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.
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.
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.
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.
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.