London Buses

How this project works

The full picture, in plain words. What the app does, where the data comes from, how the pipeline and hosting fit together, and how to set it up from scratch. Deep per-field notes live in data.md; the deployment checklist is SETUP.md.

1. What this is

London Buses is a website for exploring every bus route in London. You can search routes and see them on a map. You can watch live arrivals and live bus positions. You can browse operators, garages and stops. You can also dig into contracts: who runs each route, what it cost, and when it comes up for tender again.

It is an independent project. It is not affiliated with Transport for London. All data comes from public sources.

1b. Orientation — the whole system in one screen

If you are reading this page to understand the project quickly (as a person or as an automated agent), this section is the map; the rest of the page is the territory.

2. The big idea

The data platform is one Git repository, and the site is a folder of static files served from it. Four parts do all the work:

Why build it this way? Almost nothing has to stay online: the pipeline runs on a schedule and the site is plain files. And every data change can be reviewed in Git history.

3. The codebase — where everything lives

The repository is the single source of truth. Code, data, docs, tests and automation all live in it.

PathWhat it is
index.htmlThe whole app. HTML, CSS and JavaScript in one file.
data/api/The faux-API. The JSON files the app reads (see section 8).
data/Primary datasets the pipeline builds (route classifications, overview GeoJSON, garages, stops…).
data/source/Raw fetched material and long-lived caches (DVLA records, tender awards, parsed PDFs…).
data/route-overrides.jsonHand-written corrections. Anything set here beats scraped data.
data/audit/Cross-check reports comparing our data against independent sources.
scripts/The data pipeline. One script per step. refresh.js runs them in order.
.github/workflows/Four scheduled workflows (see section 9).
functions/api/live/vehicles.jsThe /api/live/vehicles endpoint: proxies live bus GPS (adds the secret BODS key, filters to the route).
functions/api/streetworks.jsThe /api/streetworks endpoint: receives DfT Street Manager push notifications (signature-verified) and commits each London event to the streetworks-inbox branch.
tests/Browser test suites. They mock every external feed, so they pass offline.
_headers, _redirectsResponse headers and old-URL redirects, in Cloudflare Pages format (the host's equivalent must apply the same rules — see section 11).
docs.htmlThis page.
v3.htmlA concept page at /v3 (not linked from the app, noindex): an operator-intelligence dashboard prototype built on the same faux-API files, used to plan the app's next evolution. It opens on the whole network, scopes to an operator or a garage, adds fleet-age-limit (14-year) tracking, and its maps open full page.
archive/v1/The original map-first app. Kept working, read-only.
README.md, data.md, SETUP.mdProject intro, the deep data reference, and the from-scratch deployment checklist.

How GitHub is used

4. The app

The app is a single-page app in plain JavaScript. It all lives in index.html. There is no framework and no build step. You can read the file top to bottom.

The views

URLWhat you see
#/Route search with stackable filters (type, operator, propulsion…).
#/route/25One route: map (with stop, live-bus, garage, low-bridge, collision, street-works and traffic-orders layers), status, crowding, fleet, contract history, cost-per-mile chart.
#/mapThe whole network on one map. Colour by operator or type. Filter, search, compare.
#/stops · #/stop/…Stop search (or "near me"), then a live arrivals board that counts down each second.
#/operators · #/operator/…Every operator: routes, fleet size, garages, zero-emission share.
#/garages · #/garage/…Every garage: capacity, utilisation, routes based there, on the map.
#/vehicle/…One bus by registration: make, age, fuel, live position.
#/tenderEvery TfL award since 2003 and the forward programme. Each award shows its previous operator and its new operator side by side (highlighted when they differ). Search by route, operator or tranche. Filter, sort, analyse, export CSV.
#/diversionsDay-by-day history of every TfL-notified diversion, accumulated from status snapshots. Filter and export.
#/streetworksThe statutory DfT Street Manager record for London: every permit and work event, pushed live from DfT. Filter by borough, category, status; export.
#/troThe statutory D-TRO traffic regulation orders for Greater London: closures, bus lanes, banned turns, official diversion routes. Filter by authority, regulation type, status or route corridor; export.
#/mileageScheduled-vs-operated mileage per route per TfL reporting period, joined with the diversion history.
#/cpiThe ONS CPI index and the contract price adjustment rates derived from it.
#/aboutSources, licences, and a live table of when each dataset refreshes.

How it's built

5. Data — where it comes from

SourceUsed forKey needed?
TfL Unified APIRoutes, stops, timetables, live arrivals, service statusBUS_API_KEY (free)
TfL geometry ZIPRoute line geometryNo
TfL iBus open dataEvery bus registration and its operatorNo
DVLA Vehicle EnquiryMake, fuel type and age per registrationDVLA_API_KEY (free)
DfT Bus Open Data (BODS)Live bus GPSBODS_API_KEY (free, server endpoint only)
TfL tender pages + programme PDFsContract awards and upcoming tendersNo
ONSCPI index for contract price adjustmentNo
londonbusroutes.netOperator, garage, PVR, vehicle type (community reference)No
postcodes.io / OSMGeocoding garagesNo
OpenFreeMap / CARTOBasemap tiles: OpenFreeMap vector tiles (light theme); CARTO raster tiles (dark theme and fallback)OpenFreeMap: none. CARTO: a free-tier key, public by design, in the page
DfT Street Manager open dataThe statutory street-works record, pushed live over SNS and filtered to London's 33 highway authorities + TfLFree registration (push-only; the receiver needs a GitHub token — see section 11)
DfT D-TRO serviceThe statutory traffic regulation orders behind closures, bus lanes, bus gates and diversions, searched for Greater LondonFree registration (OAuth id + key/secret as Actions secrets)
TfL Road disruptions (TIMS)Live roadworks from TfL's traffic control centre, corridor-joined to bus routesNo
TfL per-route performance PDFsOperated-mileage % and reliability per 4-week period (the QSI reports)No
london-bus-routes.fandom.comProvisional tender awards, weeks-to-months ahead of TfL's register (served flagged provisional)No
DfT STATS19, TfL datastore, TfL BUSTOCollisions, low bridges, crowding (fixed snapshots)No

Keys go in a local .env file (copy .env.example) or in GitHub secrets. The frontend needs no keys at all. It runs entirely from the committed JSON.

6. Data — how it's made

npm run refresh runs the steps below in order (the historical "17-step" numbering kept its shape as steps were added — 9b, 12b, 12c). Fetch steps come first, then the builds, and the quality gate closes the run. A fetch step may fail without stopping the run — the last good data stays. A build step must succeed. If the audit fails, nothing is committed.

#ScriptWhat it does
1fetch-data.jsDownloads TfL's geometry ZIP. Writes one GeoJSON per route.
2fetch-route-destinations.jsRoute names and destinations from the TfL API.
3fetch-route-stops.jsThe ordered stop list per route, plus one big stop directory.
4fetch-garages.jsThe garage list, geocoded via postcodes.io with OSM fallback.
5fetch-frequencies.jsReads TfL timetables. Marks each route high- or low-frequency.
6fetch-route-details.jsOperator, garage, PVR, vehicle type and contract dates per route.
7fetch-vehicle-fleet.jsJoins TfL's registration list with DVLA lookups. 90-day cache — only new vehicles are queried.
8fetch-route-vehicles.jsSamples live arrivals to see which buses actually worked each route today.
9fetch-tenders.jsEvery published TfL award since 2003. Awards never change, so only new ones are fetched.
9bfetch-fandom-tenders.jsProvisional awards from the community wiki's Tender Results pages, served flagged provisional until TfL publishes the real award.
10fetch-tender-programme.jsParses TfL's annual programme PDFs. Which routes come up for tender, and when.
11fetch-line-status.jsSnapshot of service status and diversions. The live feed still wins in the browser.
12fetch-cpi.jsThe ONS CPI index and the CPA rates derived from it.
12bfetch-scheduled-mileage.jsTimetable-derived scheduled km per route per day type.
12cfetch-route-performance.jsTfL's per-route QSI PDFs: operated-mileage % and reliability per period. Cached on Last-Modified, so most nights re-download almost nothing.
13build-classifications.jsJoins everything into one master record per route. Manual overrides are applied last and always win.
14build-overview.jsSimplifies all geometry into one network-wide GeoJSON the map can draw at once.
15build-garage-locations.jsGarage coordinates in the shape the frontend wants.
16build-api.jsAssembles the faux-API files plus a manifest. Missing fields keep their last good value.
17audit-data.jsChecks every record for plausibility (including a buses-only guard — no tram data may leak in). Critical problems abort the run.

Four more steps run only in the 2-hourly status workflow, because they accumulate history from feeds that publish no archive: build-diversion-history.js (folds each diversion snapshot into the rolling history), fetch-roadworks.js (TIMS snapshot, corridor-joined to routes, accumulated), drain-streetworks.js (folds Street Manager events pushed to the streetworks-inbox branch into the archive, converting coordinates to WGS84), and fetch-dtro.js (statutory traffic regulation orders from DfT's D-TRO service — a full Greater London search per run while the store is small, paced under the service's rate limit, switching to incremental once volumes grow).

7. Data — where it's stored

Everything is a file in Git. There is no database, no object store, no cache server. Three layers of files:

Some things are deliberately not stored: live feeds (always fetched fresh), API keys (env vars and secrets only), and node_modules (ignored).

Storage cost is a non-issue. The whole data set is a few tens of megabytes of JSON. Git compresses the daily diffs well.

8. The faux-API

The app reads these files from data/api/. They are plain JSON, committed to the repo, served as static files. They are public: no key, and the host serves them with access-control-allow-origin: *, so other sites can read them straight from the URL. Each row below says which script writes the file, what it is built from, how often it moves, and its top-level shape. Sizes are approximate and uncompressed.

FileContentsWritten byBuilt fromRefreshShape
routes.json (28 KB)Every route id, name and type (regular / night / 24-hour / school).build-api.jsTfL /Line/Mode/bus + route classificationsnightly[{id, name, type, …}]
route-meta.json (244 KB)Per route: operator, company, garage, PVR/TVR, vehicle type, propulsion, length, contract dates, fleet age.build-api.jsroute_classifications.json (londonbusroutes.net + DVLA aggregate + overrides)nightly{generatedAt, source, count, routes:{id→…}}
routes-overview.json (1 MB)Simplified network geometry for the big map.build-api.jsdata/routes-overview.geojson (TfL geometry ZIP, simplified by build-overview.js)nightlyGeoJSON FeatureCollection, one feature per route-direction
route-stops.json (7 MB)Ordered stops per route and direction.fetch-route-stops.jsTfL /Line/{id}/Route/Sequencenightly{generatedAt, routes:{id→{outbound:[…], inbound:[…]}}}
route-bboxes.json (25 KB)A bounding box per route. The live-vehicle endpoint uses it to filter GPS.build-api.jsper-route geometrynightly{generatedAt, routes:{id→[w,s,e,n]}}
garages.json (33 KB)Garages: code, name, operator, location, licensed capacity, routes based there.build-api.jslondonbusroutes.net garage list + postcodes.io/OSM geocode + DVSA licences (capacity/licences.json) + garage-overrides.jsonnightly{generatedAt, source, count, garages:[…]}
fleet.json (335 KB) / vehicles.json (2 MB)Observed vehicles per route (make, age, propulsion aggregate) / one record per registration.build-api.jsTfL arrivals sweeps (route-vehicles.json) + iBus list + DVLA VES cache (vehicle-fleet.json)3× daily{generatedAt, byRoute:{id→…}} / {generatedAt, byReg:{reg→…}}
tenders.json (3.6 MB)Every published TfL award since 2003: operator, bids, cost per mile, contract dates, previous operator; community-wiki awards flagged provisional.build-api.jsdata/source/tenders.json (TfL award pages) + fandom-tenders.json + programme dateshourly in the day{generatedAt, source, count, byId:{btID→…}, byRoute:{id→[…]}}
tender-programme.json (339 KB)The forward programme: tranche, route, issue/return/award/start dates, vehicle spec.build-api.jsTfL tendering-programme PDFs, parsed with pdf.jshourly in the day{generatedAt, source, count, sources, entries:[…]}
line-status.json (106 KB) / route-diversions.json (156 KB)Service status per route / current diversions. Fallbacks for when the live call fails.fetch-line-status.jsTfL /Line/Mode/bus/Status?detail=trueevery 2 h{capturedAt, summary, rows:[…]} / {generatedAt, count, routes:{id→[…]}}
diversion-history.json (1.4 MB)Rolling diversion archive: one entry per (route, reason) with the days it was observed. TfL publishes no history, so the snapshots are the history.build-diversion-history.jsevery route-diversions.json snapshot, mergedevery 2 h{generatedAt, firstObservedAt, lastObservedAt, snapshotCount, count, entries:[…]}
roadworks.json (70 KB) / roadworks-history.json (428 KB)TfL TIMS road disruptions, corridor-joined to routes within 30 m / the same, accumulated.fetch-roadworks.jsTfL /Road/all/Disruption + route geometryevery 2 h{generatedAt, joinMetres, count, disruptions:[…]} / {…, entries:[…]}
streetworks-history.json (10 MB)DfT Street Manager permits and works for London's 33 highway authorities + TfL: current and recently ended works, columnar-encoded.drain-streetworks.jsSNS events committed to the streetworks-inbox branch by /api/streetworks; full archive on streetworks-archiveevery 2 h{generatedAt, totalArchived, count, fields:[…], enums:{…}, rows:[[…]]} — decode a row by zipping fields, looking up enum columns in enums
dtro.json (11 MB)Bus-relevant statutory traffic regulation orders for Greater London, corridor-joined to routes, with official diversion geometry where published.fetch-dtro.jsDfT D-TRO service v4 (OAuth), polygon search + per-order fetch, watermarkedevery 2 h{generatedAt, note, count, totalOrders, publishingAuthorities, orders:[…]}
route-performance.json (610 KB)Operated-mileage % and reliability (EWT minutes or on-time %) per route per TfL 4-week period, with each route's minimum standards.fetch-route-performance.jsTfL QSI per-route PDFs, parsed with pdf.js, cached on Last-Modifiednightly check{generatedAt, periods:["P01 24/25",…], routes:{id→{operatedPct:{period→%}, reliability:{period→v}, reliabilityMetric, operatedMinStandard, reliabilityMinStandard}}}
scheduled-mileage.json (152 KB)Timetable-derived scheduled km and trips per route per day type. An estimate.fetch-scheduled-mileage.jsTfL timetables × route geometry lengthnightly{generatedAt, method, count, routes:{id→{dailyKm:{weekday,saturday,sunday}, trips:{…}}}}
cpi-cpa.json (18 KB)The ONS CPI index (D7BT, 2015 = 100) by month and the contract price adjustment rates derived from it.fetch-cpi.jsONS time-series APInightly (ONS publishes monthly){generatedAt, series, releaseDate, nextRelease, factor, months:[…], confirmedForward}
crowding.json (349 KB) / crowding-profile.json (1.5 MB)Peak load per route (busiest stop and time) / load along the route stop by stop.seeded; copied through by build-api.jsTfL BUSTO survey (fixed snapshot)static{generatedAt, year, count, routes:{id→…}}
bridges.json (225 KB) / accidents.json (2.3 MB)Low bridges with clearance / bus- and coach-involved collisions, for the route-map layers.seeded; copied through by build-api.jsTfL/London Datastore height restrictions (+OSM cross-check) / DfT STATS19 2021–2025static{generatedAt, count, bbox, bridges:[…]} / {…, period, accidents:[…]}
manifest.json (5 KB)Per dataset: source, fetch time, row count, cadence, files. Powers the About page and is the index automated readers should start from.build-api.jsthe files aboveevery run{generatedAt, datasets:{name→{source, fetchedAt, status, rows, files, cadence}}}

Besides data/api/, two things are fetched by URL: data/routes/{id}.geojson (full-fidelity geometry per route, written by fetch-data.js) and data/route_destinations.json (used by the /v3 concept page).

8b. Data conventions

9. Automations

Four GitHub Actions workflows keep the data fresh. Times are UTC. The app's About page shows the same schedule in London time.

WorkflowSchedule (UTC)Refreshes
weekly-refresh.ymldaily 03:17The full pipeline (every fetch and build step plus the audit gate). The name is historical.
refresh-status.ymlevery 2 h, 07:41–21:41Service status, the diversion register + history, TIMS roadworks, the street-works drain (folds DfT Street Manager events pushed to the streetworks-inbox branch), and the D-TRO order search.
refresh-fleet.yml07:20 / 15:20 / 23:20Arrivals sweep and DVLA fleet data.
refresh-tenders.ymlhourly 07:20–20:20, plus after every other runTender awards and the programme.

Built-in safety:

10. Live data

Three things are live and never stored:

Every live surface survives a bad feed. Last-good data stays on screen with a quiet "reconnecting…" note. Polling backs off and recovers on its own.

11. Hosting

The site is a folder of static files plus two small server endpoints. Any host that can do the following will run it; today that host is Cloudflare Pages, and SETUP.md walks through both Cloudflare and a plain Linux server.

12. Deployment

13. Keeping the data honest

14. Testing

The suites in tests/ run the real app in a headless browser. Every external feed is mocked, so they pass offline and never depend on TfL being up. They cover: mobile layout and overflow, dark and light themes, the tender tables and their analysis numbers, the CPI maths (checked against ONS reference values), the map hazard layers, live-feed outage and recovery, and the promise that no retired hosts are ever called. Run one with node tests/verify-<name>.mjs.

15. Set it up from scratch

The short version. SETUP.md in the repository is the full checklist, in order, with every secret, environment variable and registration spelled out.

  1. Get the code. It ships with working data, so the site runs immediately with no keys.
  2. Serve it locally. npm install, then any static server (npx serve .) from the repo root. Everything works from the committed JSON.
  3. Get keys (only needed to refresh data): a TfL API key, a DVLA key, and a BODS key. All free. Copy .env.example to .env and fill them in.
  4. Run the pipeline. npm run refresh. Watch the steps run. The audit at the end must pass. Each step can also run alone — see package.json.
  5. Host it. Serve main as static files with the headers and redirects from section 11, and run the two /api endpoints with BODS_API_KEY set.
  6. Automate it. Add BUS_API_KEY and DVLA_API_KEY as GitHub Actions secrets. The four workflows then keep the data fresh on their own. Public repos get unlimited Actions minutes.
  7. Optional — street works. The Street Manager feed is push-based, so it needs your own registration: give the receiver GITHUB_REPO and a fine-grained GITHUB_TOKEN, GET /api/streetworks until it reports ready, then register that URL at DfT's open-data onboarding. Everything else works without it.
  8. Optional — traffic orders. Register (free) at d-tro.dft.gov.uk, then add DTRO_CLIENT_ID, DTRO_API_KEY and DTRO_CLIENT_SECRET as Actions secrets. Without them the fetcher skips gracefully and the rest of the pipeline is unaffected.
  9. Fix data by hand when needed. Add an entry to data/route-overrides.json. Rebuild: npm run build-classifications, then build-overview, then build-api. Commit. Overrides survive every future refresh.
  10. Verify. npm run test:fixtures once (it downloads the pinned map libraries the suites serve offline), then run the suites in tests/. They need a Chromium and mock every feed.
Building a similar project for another city? The shape carries over. Find the transit agency's open API and any extra registers. Write one fetch script per source, with last-good fallbacks and caches for facts that never change. Join everything into one record per route. Serve the result as static JSON. Let scheduled CI commit the changes. The frontend only ever reads flat JSON, so it doesn't care where the data came from.

16. Glossary

PVRPeak Vehicle Requirement. How many buses a route needs at the busiest time of day.
TVRTotal Vehicle Requirement. PVR plus spare buses.
DeckDouble-decker or single-decker.
PropulsionWhat powers the bus: diesel, hybrid, electric or hydrogen.
Tender / awardTfL contracts each route through competitive bidding. The published result is the award.
TrancheA numbered batch of routes tendered together.
£/mileThe award's cost per contracted mile. The headline price of running the route.
CPI-CPAContract Price Adjustment. TfL contracts rise with 85% of CPI inflation.
iBusTfL's on-bus tracking system. Its open data lists every vehicle's registration and operator.
Street ManagerDfT's national register of street and road works. Every permit and work event in England flows through it; this site subscribes to the London slice.
SWA codeStreet Works Act organisation code — the numeric ID every highway authority carries (e.g. 5300 = Enfield). Street Manager identifies authorities by these.
TRO / D-TROTraffic Regulation Order — the legal instrument behind a closure, bus lane, banned turn or parking rule. D-TRO is DfT's digital register of them; publication is mandatory for new orders from autumn 2026.
BODSThe DfT Bus Open Data Service. The national live bus GPS feed.
Faux-APIThis project's term for committed JSON files served statically but read like an API.