Busiest US flight routes in 3D
The 30 airport pairs that carried the most passengers in 2025, from the Department of Transportation's airfare report. Arcs get thicker and warmer with the passengers a day between their two airports, from a thin violet line for the quietest route shown to a wide gold one for the busiest. The list on the map ranks them, with each route's average fare and the airline that carried the most of its passengers. New York's three airports are on 13 of the 30 routes, and Florida's on 8. Pick an airport, or click one on the map, to see its own busiest routes, and hover a route in the list to find its arc.
Build it yourself
A Node.js script averages a year of the airfare report by airport pair and keeps the 30 busiest, with each airport's location from OurAirports. A page then draws them as deck.gl arcs on a dark, tilted style of its own, with each airport's code. It's the map above without the picker or the list. It was tested with MapLibre GL JS 6.15 and deck.gl 9.4 in Chromium, and with Node.js 22.
What you need
- Node.js 18 or later.
- A browser key with the Map tiles product, made as API keys shows. Add
http://localhost:*to its allowed origins, so it works from your computer while you build. Map tiles are free, and signing up takes a minute, with no credit card. The airfare report and OurAirports need no key.
Collect the routes
Each quarter, the Consumer Airfare Report ranks the 1,000 busiest pairs of cities in the contiguous states. Its Table 1a breaks them down by airport and gives each pair's average passengers a day and average fare. Make a folder for the map, and in it a file called routes.mjs:
import { writeFile } from "node:fs/promises";
async function download(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response;
}
// One row's fields, where a quoted field can hold commas.
const fields = (row) => [...row.matchAll(/("(?:[^"]|"")*"|[^,]*)(?:,|$)/g)].slice(0, -1).map(([, field]) => field.replace(/^"|"$/g, ""));
const rows = await (await download("https://data.transportation.gov/resource/tfrh-tu9e.json?$where=year=2025&$limit=50000")).json();
const [header, ...lines] = (await (await download("https://davidmegginson.github.io/ourairports-data/airports.csv")).text()).trim().split("\n").map(fields);
const column = Object.fromEntries(header.map((name, i) => [name, i]));
const places = new Map();
for (const line of lines) {
if (line[column.iso_country] !== "US") continue;
places.set(line[column.ident], line);
if (line[column.iata_code]) places.set(line[column.iata_code], line);
}
const place = (code) => {
const line = places.get(code) ?? places.get(`K${code}`);
return [Number(line[column.longitude_deg]), Number(line[column.latitude_deg])];
};
const pairs = new Map();
for (const row of rows) {
const name = `${row.airport_1}–${row.airport_2}`;
if (!pairs.has(name)) pairs.set(name, { name, from: place(row.airport_1), to: place(row.airport_2), passengers: 0, spent: 0 });
const pair = pairs.get(name);
pair.passengers += Number(row.passengers) / 4;
pair.spent += (Number(row.passengers) * Number(row.fare)) / 4;
}
const routes = [...pairs.values()]
.sort((a, b) => b.passengers - a.passengers)
.slice(0, 30)
.map(({ spent, ...route }) => ({ ...route, passengers: Math.round(route.passengers), fare: Math.round(spent / route.passengers) }));
await writeFile("routes.json", JSON.stringify(routes));
console.log(`The busiest of ${pairs.size} routes is ${routes[0].name}, with ${routes[0].passengers} passengers a day`);
- The report's data is on data.transportation.gov, which answers queries such as
$where=year=2025with JSON rows. - Each pair's passengers a day are averaged over the year's four quarters. Its fare is averaged over its passengers, so a busy quarter counts for more.
- OurAirports lists the world's airports in one CSV. Most US airports go by their IATA code there. Its ICAO code, K followed by the code the report uses, finds the few whose IATA code has changed since.
Run it:
node routes.mjs
It prints The busiest of 2344 routes is LAX–JFK, with 6329 passengers a day and writes routes.json.
Make the page
Next to routes.json, make index.html, with your browser key in place of mapricot_pk_…:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Busiest US flight routes</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/@deck.gl/core@9.4.0/dist.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@deck.gl/layers@9.4.0/dist.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@deck.gl/mapbox@9.4.0/dist.min.js"></script>
<script type="importmap">
{
"imports": {
"maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm"
}
}
</script>
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh; background: #0b1020"></div>
<script type="module">
import * as maplibregl from "maplibre-gl";
const { ArcLayer, MapboxOverlay, TextLayer } = deck;
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: { protomaps: { type: "vector", url: "https://api.mapricot.com/tiles/v1/planet.json?key=mapricot_pk_…" } },
layers: [
{ id: "background", type: "background", paint: { "background-color": "#0b1020" } },
{ id: "earth", type: "fill", source: "protomaps", "source-layer": "earth", paint: { "fill-color": "#1a2036" } },
{
id: "water",
type: "fill",
source: "protomaps",
"source-layer": "water",
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": "#0b1020" },
},
{
id: "borders",
type: "line",
source: "protomaps",
"source-layer": "boundaries",
filter: ["<=", ["get", "kind_detail"], 4],
paint: { "line-color": "#2c3456", "line-width": 0.6 },
},
],
},
center: [-97, 38],
zoom: 3.6,
pitch: 40,
attributionControl: { customAttribution: "Routes: US DOT, OurAirports" },
});
const routes = await (await fetch("routes.json")).json();
const airports = new Map(routes.flatMap((route) => route.name.split("–").map((code, i) => [code, i ? route.to : route.from])));
// How busy each route is, from 0 for the quietest to 1 for the busiest.
const [most, least] = [routes[0].passengers, routes.at(-1).passengers];
const busy = (route) => (route.passengers - least) / (most - least);
// Its color, from violet through magenta and peach to pale gold.
const stops = [[106, 61, 201], [224, 69, 155], [255, 158, 94], [255, 226, 154]];
const shade = (route) => {
const t = busy(route) * (stops.length - 1);
const i = Math.min(Math.floor(t), stops.length - 2);
return stops[i].map((value, k) => Math.round(value + (stops[i + 1][k] - value) * (t - i)));
};
map.addControl(
new MapboxOverlay({
layers: [
new ArcLayer({
id: "routes",
data: routes,
getSourcePosition: (route) => route.from,
getTargetPosition: (route) => route.to,
getSourceColor: shade,
getTargetColor: shade,
getWidth: (route) => 1 + 13 * busy(route),
getHeight: 0.5,
pickable: true,
autoHighlight: true,
highlightColor: [255, 255, 255, 255],
}),
new TextLayer({
id: "airports",
data: [...airports],
getPosition: ([, position]) => position,
getText: ([code]) => code,
getSize: 11,
getPixelOffset: [0, -10],
getColor: [255, 255, 255],
fontFamily: "system-ui, sans-serif",
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [11, 16, 32, 255],
}),
],
getTooltip: ({ object }) => object?.name && `${object.name}: ${object.passengers.toLocaleString()} passengers a day, $${object.fare} on average`,
}),
);
</script>
</body>
</html>
- deck.gl loads from its prebuilt scripts on jsDelivr, one for each package, which share the global
deck. Its modules from jsDelivr draw the arcs too, but hovering them finds nothing. MapboxOverlaydraws deck.gl's layers on its own canvas over the map and moves them with the map. It works with MapLibre as well as Mapbox.- An
ArcLayerlifts each route from one airport to the other, higher the farther it goes. busyplaces each route between the quietest and the busiest of the 30. The quietest is 1 pixel wide and violet, the busiest 14 pixels and pale gold, and the rest fall in between, through magenta and peach. The widths stretch the differences, so read the exact numbers from the tooltip.- A
TextLayerlabels each airport with its code, outlined in the background color so it reads over the arcs.
Open it
Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. You should see arcs between the coasts, the widest from LAX to JFK, and a fan of them from New York down to Florida. Hover an arc to see its passengers and fare.
For another year, change year=2025 in the script. To show one airport's routes, keep the rows whose airport_1 or airport_2 is its code before you rank them.
Data
- Routes: the Department of Transportation's Consumer Airfare Report, Table 1a, for 2025 (public domain). It counts passengers both ways and includes trips that change planes on the way, so ORD–LGA counts people flying between Chicago and LaGuardia, not people passing through either. It comes from a 10% sample of tickets, and it covers only the 1,000 busiest pairs of cities in the contiguous states. A pair that falls out of those in one quarter counts no passengers for that quarter.
- Airports: OurAirports (public domain)
- Land, water, and borders: OpenStreetMap and Natural Earth, in Mapricot's map tiles