23 July 2026 ·

How I Built Free Sydney Train Alerts on Cloudflare Workers

There's a feeling every Cronulla line commuter knows. You're packing up in the city, you finally check the timetable app and the train you wanted left Town Hall three minutes ago. Off-peak the next one is half an hour away. The information was right there. I just looked too late. For ages I figured somebody must have built free Sydney train alerts for exactly this moment, a nudge that finds you instead of a timetable you have to check. I didn't want a journey planner. I already know how to get home! I wanted a few minutes' notice before each matching train leaves my station. And if the train runs late, the warning should shift late with it.

That's when it hit me. I write software for a living, Transport for NSW publishes live train data as open data and modern browsers can deliver push notifications with no app in sight. Everything I needed already existed. So that's what I built! It's called Train Alerts, it's free, it needs no account and the whole thing runs on Cloudflare Workers for roughly nothing. Here's how it fits together.

Web push without an app

The first big decision was delivery. A native app means two app stores, review queues and convincing you to install something for one buzz a day. Web push skips all of it. Your browser already ships a push channel: a small service worker subscribes through the Push API and the browser hands back an endpoint URL and a pair of keys. Post an encrypted message to that endpoint and the phone buzzes, even with the browser closed and the screen locked. I still think that's a bit magic!

I implemented the sending side from scratch with WebCrypto instead of pulling in a push library, because I wanted to properly understand it. Two RFCs do all the work. RFC 8292, better known as VAPID, has your server sign a short JWT with an elliptic curve key so the push service knows who's knocking. RFC 8291 encrypts the payload to the subscription's keys so nobody along the way can read your notification, not even the push service carrying it. WebCrypto on Workers gives you every primitive you need: ECDH gets you the shared secret, HKDF stretches it into keys and then AES-GCM seals the payload. It's a few hundred lines and it was my favourite part of the whole build!

The accountless part falls straight out of this. The push subscription is the identity. I never need a username or a password, just the route, the schedule and where to deliver. Prefer email instead? It does that too. Cancelling is a private link that comes with every alert.

Following live delays with the TfNSW open data API

TfNSW's open data platform gives you two main doors into live train movements. There are the GTFS-realtime feeds, protobuf firehoses of trip updates and vehicle positions for the whole network, brilliant if you're building a map. And there's the Trip Planner API, which answers a smaller question directly: what's leaving this stop soon and is any of it running late? For a departures tool it was the right fit. One call returns upcoming departures for a station with scheduled time and live estimate side by side, already in JSON.

That live estimate is the whole point. A fixed reminder at 5:10 for the 5:15 is just an alarm clock and an alarm clock lies the moment the train runs eight minutes late. So each alert keeps rechecking as departure approaches and shifts with the train. If your 5:15 becomes a 5:23, your five minute warning lands at 5:18 instead. The board comes to you.

Scheduling Sydney train alerts on Cloudflare Workers

Each alert gets its own Durable Object, a tiny single-threaded instance with its own storage and its own alarm. The loop is lovely: the alarm fires, the object pulls live departures for your origin station, filters them to the trains matching your days, window and direction, works out when it next needs to wake and sets its own next alarm before going back to sleep. One object per alert with alarms rescheduling themselves. The only cron in the whole app is a coarse safety net that re-wakes objects after a deploy. An alert for weekdays 4:45 to 6pm sleeps about 23 hours a day and sleeping costs nothing!

The matching rules carry all the commuter reality. Which days do you travel? What time window? Which direction, since your station sends trains both ways? Direction matches on each departure's line and headsign. And since plenty of people work alternating weeks, a schedule can follow a fortnightly roster where week A and week B look completely different. The durable bits, your route, schedule and delivery target, live in D1, Cloudflare's SQLite database. That's the whole backend.

The day one bug that flooded my phone

Alert number one was my own: Town Hall towards Cronulla, on my phone. Day one humbled me! The direction filter had a bug: when no Cronulla train sat inside the window it was polling, it fell back to alerting me about every train leaving Town Hall instead of staying quiet. T1 to Berowra, T2 to Redfern and T1 to Emu Plains, all inside one minute! The fix flipped the default: once a direction filter has been proven against real departures, an empty window means silence, never a flood. A notification tool only works while you trust every buzz. One wrong alert and that trust is gone.

That day also changed how I test. There's now a mock TfNSW upstream with Playwright end to end tests driving the real subscribe flow against it, plus tests that decrypt the encrypted push exactly like a browser would. A live API only shows you the trains running right now, so strange cases like an empty window or a growing delay need a fake upstream you control.

The iOS catch: Add to Home Screen

One wrinkle before you build anything on web push: iPhones will only deliver it to sites installed on the Home Screen. Safari has supported the standard since iOS 16.4 but Apple gates it behind that install step. So the site walks iPhone users through it once: tap share, Add to Home Screen, open from the new icon and allow notifications. From then on pushes arrive like any app's, locked phone and all. A one time step and I reckon it's a fair trade for never visiting an app store.

Small details that mattered

Two small features mattered more than the cryptography. The first is the I'm on board tap. Once you're on the 5:15 you don't need the 5:45 alert, so one tap mutes the rest of that day's window and everything returns next travel day. The second is the next-trains line in every alert, like "next in 2 min, following in 10 min". Sometimes you know you won't make the next one. Whether the following one is close decides if you jog! If you catch Sydney trains you can set one up in about a minute: pick your station, direction, notice and days.

What it costs and what I'd do differently

Running cost rounds to zero. A Durable Object only bills while it's awake and each alert is awake for seconds a day. D1 holds a handful of tiny rows per alert. Push delivery itself is carried free by the browser vendors' push services. It sits comfortably inside Cloudflare's cheapest tiers, which is what lets it stay free with no accounts and no tracking.

What would I change? I'd build the mock upstream on day zero instead of after the Berowra incident. The edge cases in a live feed won't visit you on a schedule, so build the fake one first.

Beyond that lesson I'm genuinely proud of where this landed. It does one small thing properly: a quiet buzz at the right moment, adjusted for the train that's actually coming, every travel day, for free. I haven't opened a timetable app since!

One practical tip before you go, for anyone building against the TfNSW Trip Planner API. The stop_finder endpoint takes a type_sf parameter and you want type_sf=any, not type_sf=stop. The stop value looks like the obvious choice but it treats your search text as a literal stop ID and answers "stop invalid" for everything else. Save yourself the head scratching! And if you've got a commute that annoys you, the data is open and the hosting costs nothing, so go scratch your own itch. Happy coding!

← All posts · Set up a free train alert