Train 515G531I24 departed 87701 two minutes late.
For TrainTracker, the first useful object was not a map, not a dot, not a pleasing animation, but a small JSON message from Network Rail with a train id, a location code, an event type, a timestamp, and a delay.
Nothing in the message says London Euston. Nothing says whether someone on board is about to miss a connection. Railway infrastructure arrives as codes because infrastructure usually does. With enough messages, and enough reference data to translate them, the system begins to move on screen.
TrainTracker turns Network Rail’s TRUST event stream into a live map of the UK rail network: active trains, recent stops, delay status, and enough state to survive a feed that does not care whether the demo is ready.
87701 was the first test
Network Rail’s TRUST system, Train Running Update and Status Tracking, is one of the places where the railway admits what it is doing. Movements, arrivals, departures, cancellations, reinstatements, and identity changes arrive as messages. During peak hours, the public feed can send several thousand events per minute.
Access is not the hard part. Network Rail provides the feed via a STOMP message queue. Making sense of it is harder.
A typical TRUST message arrives as terse JSON:
{
"header": { "msg_type": "0003" },
"body": {
"train_id": "515G531I24",
"event_type": "DEPARTURE",
"loc_stanox": "87701",
"actual_timestamp": "1704290400000",
"timetable_variation": "2"
}
}
loc_stanox is the load-bearing inconvenience. 87701 is a STANOX code. To put the train on a map, the application translates that code into London Euston, latitude 51.528, longitude -0.134. Multiply that lookup by thousands of stations, depots, sidings, and termini, and a terse operations feed becomes a coordinate system.
Message type 0003 becomes a movement. The parser reads the train id, location, actual timestamp, event type, platform, termination flag, operator, and timetable variation. If the variation status says EARLY, the delay becomes negative. If the delay is zero or better, the train is on time. One to five minutes becomes slight delay. More than five becomes delayed.
The map stops being decorative at that translation. A dot without status is only motion. A dot with a station, platform, timestamp, and delay can answer the question a passenger actually asks: is this service behaving?
Translation set the boundary
After several false starts, the system settled into a shape that fit the feed rather than fighting it. A Node.js server keeps an in-memory map of active trains. TRUST messages create, update, cancel, reinstate, or rename entries in that map. A React frontend receives state over WebSocket and renders it on MapLibre.
Before the browser sees anything, the server makes a small refusal. If a movement arrives with a STANOX code it cannot translate, and there is no existing train state to attach it to, the event is ignored. If the train already exists, the system keeps the last known coordinates and records the new delay and metadata where it can.
Unglamorous, but honest. Unknown infrastructure should not teleport a train to a guessed place. It should preserve what was known, count the miss, and wait for the next message with enough authority to move the dot.
Every minute, the state manager logs STANOX hit rate. That number is not a vanity metric. It tells the project whether the map is still reading the railway, or merely drawing confidence over missing reference data.
Deltas kept the world small
Snapshots were the tempting first thought. Send the whole world to the browser, let the map redraw, enjoy the reassuring simplicity. Rush hour corrects that idea quickly.
TrainTracker sends deltas. When one train moves, the server broadcasts that train’s new state. Browsers keep a local copy of the world and apply updates as they arrive. Bandwidth stays boring, which is what bandwidth wants.
// Server broadcasts only what changed
{ type: "update", train: { trainId: "515G531I24", lat: 52.477, lng: -1.898, status: "on-time" }}
// Not the entire state of the world
{ type: "snapshot", trains: [/* thousands of trains */]}
Each train carries a short memory: origin, current station, latest event type, platform where available, operator code, delay minutes, and the five most recent stops. Hover over a dot and you see where the train is, but also the small trail that explains how it got there.
A short trail matters because a delay is not just a number on a dashboard. Delay learns to travel. A late departure around a junction can become a line of amber and red dots that explains the next half hour better than a timetable ever could.
Colour answers first
Status comes before admiration, so the visualization uses three colors:
- Green: On time. All is well.
- Amber: 1-5 minutes late. Minor delay. You might make your connection.
- Red: More than 5 minutes late. Significant delay. Adjust your plans.
Traffic-light status requires no legend, though one is provided. A glance tells you whether today is a kind day for British rail.
Distribution tells the story the individual dot cannot. A sea of green with scattered amber feels like a system under control. Red spreading around a junction is different. It says the delay has learned to travel.
State survived silence
Realtime systems fail in ordinary ways. Network Rail’s feed drops. Connections time out. WebSockets refuse to reconnect with any clear sense of occasion. A map that goes blank during those moments tells the wrong story.
Every 30 seconds, the server writes current train state to disk. On restart, it reloads that file and discards only trains that have not reported in over two hours. A deployment cannot wipe Britain clean. Active services keep their place.
On the frontend, the contract is different. After three failed socket attempts, it switches to 12 mock trains at major UK stations, with mixed delay states. That fallback began as development convenience and became part of the product contract. An empty map looks broken. Mock data with a reconnecting indicator tells the truth: the product is alive, the feed is not currently cooperating.
Delay became propagation
During a winter storm, the value is not that the dots are pretty. Delay stops looking like a headline and starts looking like propagation. It clusters around junctions, spreads along routes, and reveals how much of the railway is a dependency graph with passengers inside it.
Morning rush hour on a clear day is different. Trains stream into London from all directions, a slow-motion implosion of green dots converging on a handful of termini. The evening rush reverses the flow, an explosion outward to suburbs and satellite cities.
Late at night, activity drops to a trickle: freight services, overnight maintenance trains, the last passenger services making their way to depots.
Complex systems become easier to reason about when they stop being averages and start being visible state.
A railway became legible
Implementation stays deliberately ordinary: React 19, MapLibre, Node.js, WebSocket, STOMP, and a STANOX lookup with more than 2,000 entries. Ordinary is the point. The interesting work is not a novel rendering trick. It is the chain of custody between an operations message and a public map.
515G531I24 at 87701, departure, two minutes late, is not much of a sentence. Translate the code, preserve the state, broadcast only the change, survive the restart, and colour the consequence correctly, and it becomes something else.
With enough of those messages, Britain starts moving.
The source code is available on GitHub. Data provided by Network Rail.