Project build · Stockholm, 2026

Watching My Own
Train Come In.

I wanted to see my commuter train approach on a map before running to the platform. That turned into a live map of every metro, commuter train and tram in Stockholm, gliding along the real tracks. Three of the problems were much harder than they looked.

Lines
26
metro · commuter rail · trams
Positions
Every 5 s
GTFS Realtime via Trafiklab
Servers
None
static page · two edge functions
Monthly cost
0 kr
free tiers · measured, not hoped
The short version

The map shows every train in Stockholm that reports a position, moving smoothly along tracks taken from OpenStreetMap. Click a station for departures, click a departure to fly to that exact train, star your home station and the map opens there. It is a PWA (progressive web app) you can install on a phone, it works in English and Swedish, and it runs on zero servers for zero kronor.

The interesting parts are the ones that fought back: making trains glide on a six-second GPS feed where one fix in twelve is garbage, drawing lines along real rails instead of guessed curves, and matching a departure board entry to a live train when the two data sources share no identifiers at all.

A Map I Actually Wanted

Stockholm has apps that tell you when your train leaves. None of them show you where it is.

The departure board says three minutes. Is that three minutes as in “start walking”, or three minutes as in “the train is still in Solna and the board is guessing”? Those are different sprints. What I wanted was embarrassingly simple: a dot on a map, moving, that is my train.

Sweden’s public transport data is remarkably open. Trafiklab publishes live vehicle positions for the whole SL fleet, full timetables, and a keyless departures API. The raw ingredients were all there. So it became a weekend project, and then, the way these things go, it became a system with a nightly data pipeline, a geometric verification gate in CI, and opinions about what honesty means in a UI.

The scope rule I set early: trains only. Metro, commuter rail, trams and the local light railways. A rail map with two thousand buses on top is a bus map.

Trains Glide. They Don’t Teleport.

The GPS feed updates each vehicle roughly every six seconds, and about one fix in twelve implies the train did 600 m/s through the old town. Draw that raw and the whole map twitches like popcorn.

The engine anchors each train to its last believable fix, then advances it along its own line at timetable pace until the next fix arrives. Impossible fixes are rejected outright. When reality and the drawn position disagree, the correction is applied to time, never to position: the train runs a little fast or slow for a few seconds until they agree, exactly like a driver making up a delay. Easing the position directly drags trains sideways off their rails, and because the feed publishes in batches, the whole fleet used to hiccup at once.

The subtlest bug took a platform test to find: the fleet ran a consistent 20–30 seconds behind reality. The fix a train reports is already ~17 seconds old by the time it reaches the browser (feed latency, polling, edge caching), and the engine was placing trains where they had been, not where they were. Crediting each fix with its own age, at timetable pace, closed the gap.

A pleasant side effect: since the engine knows where the train is versus where the timetable says it should be, the map can show honest lateness. The popup says “4 min behind schedule” and the dot’s ring tints amber, because it is measured, not scraped from anywhere.

The Tracks Are Real

Version one drew smooth curves between stations. It looked tidy and was completely wrong: commuter trains swept in an elegant arc across open water, above a tunnel they were actually inside.

Timetable data only knows where stations are, not where rails go. So the map now routes every line over a track graph built from OpenStreetMap, where Stockholm’s rails are mapped metre by metre, tunnels included.

That sentence hides a month of traps. The two directions of a railway are mapped as separate tracks that only meet at physical crossovers, which can be tens of kilometres apart; a station snapped to the “wrong” track was once routed via a crossover 31 km away. The router needed penalized hops between parallel tracks, the way a real train changes tracks at a switch. Twin tunnel tubes have exactly equal cost, so two lines through the same stretch would pick different tubes on a coin flip and draw a lens that splits and rejoins; a deterministic tie-break settles every route into the same tube.

The structural fix, after several rounds of artifacts at interchanges, was choosing snap points jointly per line: a dynamic-programming pass over candidate tracks at every station, minimizing routed length plus snap distance, with choices pinned so sibling lines reuse the same points and shared trunks coincide by construction. Independent nearest-point snapping, the obvious approach, kept dragging lines onto each other’s tunnels.

And one lesson about cleanup passes: the de-spiking filter that removes routing artifacts once deleted a station, because line 7 genuinely hairpins at Bellmansro on the Waldemarsudde balloon loop. Geometry that looks like a bug sometimes is the truth. Station vertices are protected now, and a CI gate verifies every published build geometrically: marker-to-line gaps, spike counts, schedule consistency. The two bugs that reached production both would have died there.

0lines
routed stop-to-stop over real OSM rail
0stations
worst marker sits 0.2 m from its line
0kr / month
R2, Pages Functions and Actions, all inside free tiers

Nothing Runs When Nobody’s Looking

A static page, two Cloudflare edge functions that execute only when a browser asks, an R2 bucket, and one scheduled GitHub Action a night. That is the whole system.

The live feed is protobuf behind an API key with no CORS, so one edge function holds the key, decodes it, joins vehicles against the nightly-built timetable, and returns clean JSON. A second function serves the line geometry from the same build, because trip schedules are measured as distances along those exact lines; I learned the hard way that any cache between the two must expire on the same clock, after a midday rebuild served new geometry with the morning’s schedule and put trains kilometres off their lines for anyone unlucky.

Every night a GitHub Action downloads the timetable (SL rotates its trip identifiers daily), routes all 26 lines over the track graph, verifies the output geometrically, and publishes to R2. If any gate fails, nothing publishes and yesterday’s good build keeps serving.

The rule that shaped the failure modes: never animate stale data as if it were live. If the position feed is lost, the map says so in amber and falls back to timetable estimates. The alternative, quietly replaying old positions, is how you teach users to distrust a map.

The Last Ten Percent

Most of what makes it feel finished shipped in a single long day of feedback rounds with one very demanding user: me, on a platform, with a train to catch.

Click a station and departures arrive split by direction, so you never need to know that even line numbers go north. Click a departure and the map flies to that exact train. That last one sounds trivial and is not: the departures API and the vehicle feed share no identifiers whatsoever, so the match runs on line, destination, and scheduled time at that stop, where “that stop” is found by position along the line rather than by name, because the same physical station has different names per mode. Commuter rail calls T-Centralen “Stockholm City”, and name matching silently lost every commuter train there.

Star a station as home and the map opens there with the departure board up. Interchange stations render as one ring with a connector capsule at close zoom, because T-Centralen’s blue platforms really are a block from the red and green ones, and three separate squares read as three stations. Station popups link to Albert Guillaumes’ hand-drawn cross-sections for the 105 stations he has drawn, with credit; his site deserves the visit.

And it installs. A manifest, a scoped service worker for an offline shell, and a one-tap install button where the browser offers one. On a phone home screen it opens full-screen at your home station, which is the entire original itch, scratched.

Accuracy is the product.

A live map has exactly one job: being right. Every shortcut I tried to take on geometry or timing surfaced within hours as a screenshot of something subtly wrong, because on a map of a city you live in, you can always check. The bar that emerged: close enough to perfect that you would have to dig to notice, which for 99.999% of users is indistinguishable from perfect.

The other lesson is about free infrastructure. Zero kronor a month is not the same as zero engineering: it means quota discipline, edge caching with deliberate expiry, kill switches, and verification gates instead of dashboards. The constraint made the system better, not worse.

The map is live at maclaine.se/stockholm-sl-map, in English too, with a longer story on the about page and a project page on GitHub. Unofficial, unaffiliated with SL, and free.

Project · Stockholm SL Map
Live · July 2026
Written · August 2026
Back to blog