One backend, thousands of phones: serving live ferry data without hammering WSDOT
FerryWatch shows a live ferry position to everyone watching a route — without every phone polling the WSDOT API. Here's the fan-out architecture, the cache that makes it nearly free, and the failure modes we designed around.
FerryWatch shows you where your boat actually is, right now, on the Bainbridge ↔ Seattle run. The data comes from Washington State's public WSDOT Ferries API. The obvious way to build that is also the way that gets you rate-limited into oblivion: have every phone poll WSDOT directly. We didn't. Here's the shape we used instead, and why.
The naive version, and why it fails
The first design every ferry app reaches for is one arrow: phone → WSDOT. It works in the simulator. Then you ship, and three things go wrong at once.
- You are now a load generator. A public agency API is sized for reasonable use, not for N phones each polling every few seconds during the commute. Do that and you either get throttled or become the reason they add an API key.
- Every phone pays the full cost. The raw feed is not what the UI needs. Each device would parse the whole vessel list, filter to the route, reconcile schedule vs. actual, and compute an ETA. That's battery and cellular data spent N times over to produce the same answer.
- You have no shot at a Live Activity. The whole point is glancing at your Lock Screen without opening the app. A phone can't poll a third-party API in the background every minute. Only a server can push.
So the arrow has to go through us.
The fan-out: one poller, many readers
The backend is the only thing that talks to WSDOT. It polls on a single timer, normalizes the result once, and writes it to a cache keyed by route. Every phone reads that cache — never the upstream.
The economics flip completely. Upstream calls are a function of routes, not users. One route being watched by four people and one route being watched by four thousand cost WSDOT exactly the same: one poll. On our side, a request from a phone is a single cache read of a small, already-shaped JSON blob — no upstream call, no re-parsing, no per-device ETA math done twelve thousand times.
Normalizing once, on the server, also means the app and the widget and the Live Activity all render the same numbers. When the ETA math lives on each client, they drift, and users notice when the widget says 6:48 and the app says 6:51.
Normalize to what the UI actually needs
The cache doesn't store the raw feed. It stores the answer to the question the screen is asking:
{
"route": "bbi-sea",
"vessel": "Wenatchee",
"position": { "lat": 47.62, "lon": -122.45 },
"etaSeconds": 540,
"status": "on_time",
"updatedAt": "2026-07-07T16:41:03Z"
}updatedAt is load-bearing. It's how a client knows whether it's looking at fresh data or a stale blob from before the backend lost its connection — which brings us to the failures.
Design for the upstream being down
Public feeds go quiet. The interesting engineering is entirely in what happens when the arrow into the poller breaks.
- Serve last-known, labeled. If WSDOT is unreachable, the cache keeps the last good value and the client shows it with an explicit "OFFLINE" marker rather than a spinner or a lie.
updatedAtdrives that badge — past a staleness threshold, the UI stops pretending the boat is where it was ten minutes ago. - Decouple poll cadence from failure. The poller backs off when upstream errors instead of hammering a struggling API, and recovers on its own when the feed returns. Phones never see this; they're reading the cache the whole time.
- Cache the published timetable separately. The live position can go dark, but the day's scheduled sailings don't change minute to minute. Caching the timetable independently means "when is the next boat supposed to leave" survives a live-feed outage — and works underground, where a commuter actually needs it.
The part only a server can do: Live Activities
The Lock-Screen countdown is the feature, and it exists because the backend already holds fresh, normalized state for every route. When you start a Live Activity, the app hands the backend an APNs push token bound to that activity and the route slug it's watching. The backend pushes an update roughly every minute as the cache changes, and the ETA ticks down while your phone sits in your pocket.
That single design decision — one server owning the WSDOT relationship — is what makes the marquee feature possible, not just cheap. A phone polling a third-party API can't update your Lock Screen. A server sitting on a warm cache can.
The rate limits and payload rules on the push side are their own adventure — that's the next field note.