The idea
TFL (Transport for London) publishes live arrival predictions for every tube train through their Unified API. The data is free and I thought: what if I could take those predictions and actually show every train moving across London in real time, on a 3D map?
Turns out you can. But the gap between "TFL gives you arrival times" and "smooth 3D trains gliding along accurate track geometry" is way bigger than I expected. This post is about everything that lives in that gap.
Live demo: minilondon3d.xyz
You can tap any train to see its full route and upcoming stops, or tap a station to see all approaching trains with live countdowns. There's also a service status panel showing disruptions across all lines.
Architecture overview
The system has three main pieces:
- A Python worker that polls the TFL API every 60 seconds, processes the raw data, and writes to Redis
- A FastAPI server that reads from Redis and pushes updates to frontends over WebSocket
-
A Next.js frontend that receives train data, animates positions along polylines using
requestAnimationFrame, and renders everything with Three.js on top of MapLibre GL
The worker and API server are separate processes. They communicate entirely through Redis: the worker writes cached train data and publishes updates on a pub/sub channel, the API server subscribes and relays to WebSocket clients. This means I can restart either one independently, and the API server stays completely stateless.
Building the foundation: static route data
Before a single train can be placed on the map, the system needs two things: accurate track geometry (the physical shape of each line on a map) and a station coordinate index (where every station is and how stations are ordered along each line). These come from completely different sources and get loaded once at startup.
Track geometry: GeoJSON
TFL's own route endpoint only gives you straight lines between stations, which looks terrible on a map. Real tube lines curve, run parallel, split at junctions. I got accurate geometry from Oliver O'Brien's GeoJSON file.
The problem is it comes as many tiny disconnected line fragments. The server's first job at startup is chaining these into continuous polylines. The algorithm picks a fragment, scans for others whose start or end matches within about 5 meters, flips and chains them together, and repeats until nothing else connects. This reduces something like 47 fragments down to 3-5 continuous polylines per line (roughly one per branch).
Station data: TFL's Route/Sequence endpoint
The live arrivals endpoint gives you naptanId, stationName, timeToStation, and other prediction data for each upcoming stop, but no coordinates. No lat, no lon. If you want to know where King's Cross actually is on a map, you need a separate data source.
That source is TFL's /Line/{id}/Route/Sequence/{direction} endpoint, which I fetch for every line in both directions at startup. This endpoint returns two important structures:
stopPointSequences is where station coordinates and branch topology live. Each entry in this array represents a branch: a segment of track between junctions. For a simple line like Victoria, there's basically one branch. For the Northern line, there are several.
Each branch contains an ordered list of stop points with their naptan IDs, names, coordinates, zones, disruption flags, and which other lines serve that station. It also contains nextBranchIds and prevBranchIds, which describe how branches connect at junctions. This is essentially a graph of the line's topology.
I use this branch graph for validation. Before attempting polyline extrapolation between two consecutive stops, I check that both stops appear in the same branch. If they don't, the two stops straddle a junction and polyline math would project the train onto the wrong branch.
orderedLineRoutes gives complete end-to-end route variants. While stopPointSequences gives you the physical branch segments, orderedLineRoutes gives you the full journeys that trains actually make. Each variant is a name and an ordered list of naptan IDs covering the full route from origin to terminus.
These variants are critical for data cleaning. When a train shows up with a list of predicted stops, I match it against all variants by finding the one whose naptan ID list contains the most of the train's stops. This tells me which specific route the train is running, which in turn gives me the correct geographical ordering of stations. That ordering becomes the source of truth for detecting and filtering out stale or inconsistent predictions.
All of this gets built into an in-memory station coordinate index (keyed by both naptan ID and normalized station name), a set of route variant definitions, and a branch topology graph. The whole thing gets cached in Redis so it survives restarts without re-fetching from TFL.
Pre-computed segment table
With both the track geometry and station positions loaded, the server does one more thing at startup: it walks every route variant's station list pairwise, snaps each station onto the polylines, slices the polyline between each consecutive pair of stations, and stores the resulting geometry. Both forward and reverse directions are cached.
When a user later clicks a train and requests its full route path, the server just concatenates pre-computed segments. No geometry work at request time. This brought the path endpoint from 200-400ms down to essentially a dictionary lookup.
The hard part: where is each train?
Now we have geometry, station coordinates, route variants, and branch topology all loaded. The worker starts polling TFL's arrivals endpoint every 60 seconds, which returns raw predictions for every active train across all lines.
But remember: TFL doesn't give you GPS coordinates for trains. What you get is a list of arrival predictions: "Vehicle X will arrive at Y in Z seconds"
For each prediction, we know the naptan ID of the station, but we look up that station's lat/lon from our own pre-built station coordinate index. TFL never tells us where the train is. We have to figure that out.
Polyline-based extrapolation (the good one)
Take the train's next two upcoming stops. Look up their coordinates from our station index. Snap both onto the line's track polylines. If they both land on the same polyline segment, extrapolate backward from the first stop. Then interpolation happens on the frontend as smooth animations.
Before doing this, the system checks the branch graph to confirm both stops are on the same branch. This prevents the math from projecting Northern line trains onto the wrong branch at junctions.
Straight-line extrapolation (fallback)
When polyline snapping fails (the two stops land on different polyline segments, or there's a junction crossing between them), I fall back to simple linear extrapolation between the two station coordinates. Less accurate, but it keeps the train roughly where it should be.
Both approaches require at least two upcoming stops with valid coordinates. In the rare cases where that's not available (a train approaching its terminus with only one prediction left, or a station coordinate lookup failure), the system falls back to parsing TFL's currentLocation text (strings like "Between King's Cross and Angel" or "At Finsbury Park"), and as a last resort, just places the train at its nearest upcoming stop.
Snapping to tracks (and not the wrong track)
Bearing-aware snapping
Here's a problem I didn't anticipate. The Northern line has two branches through central London (Bank and Charing Cross) that run geographically very close together. A naive "snap to nearest polyline" approach would sometimes snap a Bank branch train onto the Charing Cross tracks, because they're only a few hundred meters apart.
The fix: bearing-aware snapping. When snapping a point, I also pass the estimated direction of travel. The algorithm scores each candidate segment by combining distance and bearing alignment, with the bearing penalty weighted 3x relative to distance. A segment that's farther away but aligned with the train's direction of travel beats a closer segment that's angled off. Anything more than 60 degrees off the train's bearing gets rejected entirely before scoring.
This single change fixed most of the "train on wrong branch" bugs.
TFL data quality issues (there are many)
Working with TFL arrival predictions taught me that real-time transit data is messy in ways I really didn't expect.
Duplicate vehicle IDs across lines
TFL reuses numeric vehicle IDs across different lines. Vehicle "240" on Bakerloo and "240" on Piccadilly are completely different physical trains. If you group predictions by just vehicleId, you get Frankenstein trains with stops on two different lines. I group by (vehicleId, lineId) and create composite IDs like bakerloo_240 to keep them separate.
DLR has no vehicle IDs at all
DLR trains are driverless and TFL doesn't assign them vehicle IDs in the arrivals endpoint. Every single prediction comes with vehicleId: "000". To get DLR trains on the map, I synthesize unique IDs by combining the line, destination, direction, and a rank within each station group. It's a hack, but it works.
Stale predictions and mixed snapshots
This was the nastiest data quality issue and it took me the longest to figure out.
You get situations where a train has already passed a station, but the api still reports a low timeToStation prediction for it. Or the same train has predictions from two different moments in time, so the time-ordered stop list disagrees with the actual geographical order of the stations.
If you just sort by timeToStation and trust it, you get trains that appear to jump backward or zigzag.
The fix uses the route variant's geographical station ordering as the source of truth (this is why resolving the variant first matters so much). First, I drop any stops that are geographically behind the train's anchor position along the variant. Then I sort the remaining stops by their position along the route, not by time. Then I walk backward through the list and drop any stop whose time exceeds a later stop's time (a reverse monotonic filter). Finally, there's a plausibility check: if the first stop is many stations away from the second but only a few seconds apart in time, the first one is stale and gets dropped. A tube train needs at least good amount of time per station, so "5 stations in 20 seconds" is obviously wrong.
Duplicate platform predictions
TFL returns one prediction per platform, not per station. At a shared termini you can get 5+ predictions for a single stop because TFL broadcasts to all possible platforms before the platform is assigned. I deduplicate by naptanId to keep one entry per station.
Station name inconsistencies
TFL spells the same station differently across endpoints and lines. I built a normalizer to handle this, mainly so the station index doesn't end up with duplicate entries and so display names stay consistent across the UI. It also serves as a fallback for the rare cases where a naptan ID lookup fails and the system has to match by station name instead.
The frontend: animating 400+ trains at 60fps
Client-side polyline animation
The backend sends updated positions every 60 seconds. If I rendered those directly, trains would teleport every minute. Instead, the frontend builds an animation chain for each train.
Three.js instanced rendering
Every train is rendered as a small 3D shape (a simplified body with a pointed roof) using Three.js. TThree.js InstancedMesh added to MapLibre as a custom layer, sharing the same WebGL context as the map. Since MapLibre doesn't know the trains exist, clicking on them requires manual raycasting against the instanced mesh.
WebSocket with REST fallback
The frontend opens a WebSocket connection for real-time pushes. If the connection drops, it automatically falls back to REST polling every 30 seconds while attempting to reconnect with exponential backoff (starting at 1 second, capping at 30 seconds).
On the backend side, the worker never talks to WebSocket clients directly. It publishes updates to a Redis pub/sub channel. The API server subscribes to that channel and relays messages to all connected WebSocket clients, grouped by subscription rooms.
If you've read this far, thanks ❤️ I'd love to know your feedback!

Top comments (0)