Live MBTA subway arrivals, commute reminders, and lock-screen Live Activities on iOS, backed by an event-driven, multi-cloud data platform.
MBTALive shows Boston riders live subway arrivals, plain-English service insights, and smart commute reminders, with home-screen widgets and lock-screen Live Activities that keep live arrival times updating while the phone is locked. The app is deliberately thin: a SwiftUI client that talks only to an owned, versioned API. Everything else lives server-side. Most transit apps are a thin wrapper over the agency's public API; this project explores what it looks like to build the whole platform behind one.
That platform ingests MBTA's real-time feed continuously and turns it into things a client alone can't produce: historical on-time-performance analytics, live delay detection, and AI-written service notifications. Real-time events stream through Kafka to independent consumers; a Backend-for-Frontend serves the app a stable /v1 contract (REST and a reshaped SSE stream) off a Kafka-fed hot cache, keeping the MBTA key off the device entirely.
The whole system is cloud-native and portable by construction: one Terraform codebase stands it up on AWS EKS or GKE, delivered via ArgoCD GitOps with canary rollouts. Kafka, PostgreSQL/TimescaleDB, object storage, secrets (Vault), and the full observability stack (OpenTelemetry → Prometheus → Grafana → Tempo) are all self-hosted so the platform runs the same on either cloud, with default-deny networking and signed, digest-pinned container images throughout. That portability doubles as resilience: if one provider has an outage, a terraform apply on the other brings the core read path (live arrivals) back up without a rewrite, so users aren't stuck waiting out a single cloud's downtime while analytics and history restore behind it.
MBTALive grew from a simple idea. I kept just missing my trams, and each miss meant waiting a few minutes for the next one, usually enough to leave me cutting it close for class. I wanted an easy way to stay a step ahead.
So I built one. First came a widget that shows the next three live arrivals at my stops right on the home screen, so I can check them at a glance without opening the app. Then I added a commute section: I tell it when I need to arrive, and it sends a notification early enough that I know exactly when to head out.
It does just what I hoped: it helps me catch my tram and plan my commute with time to spare.
MBTALive_demo.mp4
Two paths share one platform. On the read path, the app talks only to transit-api, a Backend-for-Frontend that owns the /v1 contract, keeps the MBTA key server-side, and serves live predictions from a Kafka-fed hot cache (falling back to a cached MBTA proxy on a miss) plus a reshaped SSE stream for live updates. On the event path, ingestion streams MBTA's real-time feed into Kafka (running active/standby for high availability), where independent consumers fan out: delay-detector flags delays by joining predictions against schedules, sink-writer persists a time-series history to PostgreSQL/TimescaleDB and a MinIO data lake, and commute-notifier turns commute impact into notifications delivered through apns-pusher. insights-api reads that history back for the app's analytics, with plain-English summaries from Amazon Bedrock.
The platform is deliberately factored into nine focused services, each split on a real boundary; the full rationale is in the platform README.

The event path's output: a delay heads-up on the lock screen, written by the LLM (Amazon Bedrock / Nova) at the end of the MBTA → Kafka → delay-detector → llm-gateway → apns-pusher pipeline.
| Layer | Technology | Why this over alternatives |
|---|---|---|
| iOS app | SwiftUI + WidgetKit + ActivityKit | Home-screen widgets and lock-screen Live Activities (the app's core surfaces) are native-only iOS APIs: a web/PWA wrapper can't do them at all, and React Native/Flutter would still need native Swift for them, so an all-SwiftUI app is the coherent choice over cross-platform. |
| App API | transit-api, a Go Backend-for-Frontend with an owned /v1 contract (REST + reshaped SSE) |
Decouples the app from MBTA's JSON:API schema, keeps the MBTA key server-side, and lets N devices share one cache and one rate-limit budget. |
| Streaming backbone | Apache Kafka via the Strimzi operator (KRaft) | Real upstream Apache Kafka in-cluster, so the streaming layer is identical on any Kubernetes: EKS, GKE, or local kind. |
| Stream processing | Go consumers (delay-detector, sink-writer) |
Small, fast, per-partition consumers that scale independently on consumer lag rather than CPU. |
| Storage | CloudNativePG (PostgreSQL/TimescaleDB) + MinIO | Time-series-friendly relational storage plus S3-API-compatible object storage, both self-hosted so the data layer isn't tied to one cloud. |
| Caching | Redis + a Kafka-fed hot cache in the BFF | Serves the app's reads from the platform's own stream instead of hitting MBTA per request; misses fall back to a cached proxy. |
| AI | Amazon Bedrock (Amazon Nova) behind an llm-gateway |
One interface fronts the model, so swapping providers (Vertex AI / Azure OpenAI / self-hosted) is a config change, not a rewrite. |
| Identity | account-service: Apple & Google Sign-In, own session JWTs |
Login unlocks exactly one thing (cross-device commute sync), so everything else stays unauthenticated. |
| Observability | OpenTelemetry → Prometheus, Grafana, Tempo | Vendor-neutral, self-hosted metrics and traces: the same observability across clouds. |
| Secrets | HashiCorp Vault + External Secrets Operator | Self-hosted secret management that runs the same on any cluster, the same rationale as Strimzi for Kafka. |
| Networking | Envoy Gateway (Gateway API) + default-deny NetworkPolicies | Modern Gateway API ingress with an explicit per-service allow-list: the pod network is locked down, not flat. |
| Delivery | Terraform (EKS / GKE / kind) + ArgoCD GitOps + Argo Rollouts | One codebase stands the platform up on either cloud; changes ship via GitOps with a canary + manual-promote gate. |
| Supply chain | GitHub Actions CI + keyless cosign signing + digest pinning | Every image is signed and the deploy is pinned to that exact digest, so :latest never reaches a cluster. |
- Live arrivals: real-time predictions per stop with a live/scheduled indicator, blended from an SSE stream with a schedule fallback.
- Commute reminders: set a target arrival time and a lead time; a notification fires ahead of departure, using live train times to pick the right train.
- Widgets & Live Activities: home-screen widgets (next arrivals, plus a favorites carousel with an in-widget button that cycles through your saved stops), and a lock-screen Live Activity that keeps live arrival times current while the app is closed.
- Insights: per-route on-time-performance trends with a plain-English AI summary.
- Near Me & search: nearest-stop lookup and station search across the full stop list.
- Event ingestion: a single, highly-available (active/standby) SSE→Kafka producer for MBTA predictions, vehicles, and alerts.
- Delay detection: joins live predictions against schedules to emit delay events as they form.
- Time-series history: persisted to PostgreSQL/TimescaleDB and a MinIO data lake, read back by a dedicated Insights API (CQRS-lite).
- Backend-for-Frontend: an owned
/v1contract (REST + reshaped SSE) with a Kafka-fed hot cache and a bounded, serve-stale-on-error tail. - AI notifications & summaries: commute-impact notifications and route summaries via Amazon Bedrock behind a provider-agnostic gateway.
- Multi-cloud IaC: one Terraform codebase provisions AWS EKS or GKE (and a local kind cluster) behind a common interface.
- GitOps delivery: ArgoCD syncs every service, with Argo Rollouts canary and manual-promote gates.
- Security: default-deny NetworkPolicies, Vault + External Secrets Operator, and opt-in App Attest at the app edge.
- Supply chain: keyless cosign image signing and digest-pinned deploys, enforced in CI.
- Observability: OpenTelemetry traces plus Prometheus/Grafana metrics with p50/p95/p99 SLOs and Alertmanager routing.
Beyond the core features, the pieces a real production deployment needs to run reliably:
- Remote Terraform state: S3 / GCS backends with state locking, bootstrapped ahead of the infra it tracks.
- Environment tiering: a lightweight tier for cheap validation against real cloud infra before a full-size stand-up.
- Resource guardrails: namespace ResourceQuota + LimitRange and a PodDisruptionBudget per service.
- Backups & proven recovery: continuous Postgres WAL archiving plus scheduled backups, with a restore-test that rebuilds a second cluster and measures real RTO.
- Schema migrations: versioned up/down SQL with real rollback (golang-migrate), not a blind re-run.
Some of these are conscious trade-offs for a build at this scope. Here's what I'd revisit to run it in production at real scale:
- The nine services would consolidate to about five in production. Each split here is on a real boundary (independent scaling, failure domain, or data ownership), but with a small team and a real budget I'd merge them into roughly five: edge (
transit-api), ingest (ingestion), one pipeline worker (delay-detector+sink-writer),account-service, and one notifications service (commute-notifier+apns-pusher). The finer split is deliberate: it's what makes per-consumer KEDA autoscaling, independent failure domains, and end-to-end tracing across an async pipeline legible enough to demonstrate. Full reasoning in the platform README. - Failover is a redeploy, not active-active. Today, portability means I can bring the read path back with a
terraform applyon the other cloud, which is great for riding out an outage but isn't live active-active with automated cross-region data replication yet. That's the natural next step for real HA. - Self-hosted for portability, managed for scale. Everything runs in-cluster so it behaves the same on EKS, GKE, or kind, which was the whole point. If I committed to a single cloud in production, I'd happily trade some of that portability for managed services (MSK, Aurora, managed Prometheus) to cut operational load, and the Terraform layer is set up so that's a swap, not a rewrite.
- The LLM calls would need to earn their cost at scale. Bedrock is invoked per event right now. At real rider volume I'd batch and cache summaries, add concurrency and cost guardrails, and put a circuit breaker in front so a slow model never backs up the pipeline.
- Auth and abuse protection are intentionally minimal. Only commute sync needs a login, and App Attest is opt-in. For a public launch I'd turn App Attest on by default and add per-user rate limits and an edge WAF.
- Testing would grow another layer. It's solid unit tests plus load and SLO checks today; production would want a staging environment, cross-service contract tests, and synthetic end-to-end monitoring to catch regressions before riders do.
- Observability is missing its third leg. Metrics and traces are wired up, so the gap is centralized log aggregation and the error-budget and on-call runbooks that turn the SLOs into something you can operate against.
The read path is iOS app → transit-api → MBTA v3 API, so latency is dominated by one dependency the app doesn't control (MBTA). The work is therefore about avoiding the upstream call, serving from push, and bounding the tail, held to explicit percentile targets:
| Percentile | Target | Why |
|---|---|---|
| p50 | < 200 ms | Steady state is a cache hit (Kafka-fed hot cache / fresh proxy cache), served from memory, not the network. |
| p95 | < 600 ms | Occasional proxy call to MBTA on a cache miss. |
| p99 | < 1 s | Tail is capped by MBTA_TIMEOUT (4 s) + serve-stale-on-error, not MBTA's worst case. |
How they're held:
- Kafka-fed hot cache (on by default):
/v1/predictionsis served from the platform's own prediction stream in memory, so the common case never touches MBTA. - In-memory TTL cache: on a hot-cache miss, N devices collapse into one MBTA call per TTL window per pod.
- SSE push, not polling: live updates arrive over one open stream, removing repeated request latency.
- Bounded tail: every MBTA call has a tight timeout, and on timeout/error the handler serves the last cached value (
Cache.GetStale) instead of a 502.
The targets are enforced, not just documented: k6 thresholds fail the load test if a percentile is missed, and a Prometheus SLO alert fires on a sustained p99 breach. Full rationale in docs/performance-slos.md.
Measured: local transit-api, k6 at 30 VUs for 60 s (~1,800 requests, proxy-cache mode):
| Percentile | Target | Measured |
|---|---|---|
| p50 | < 200 ms | 2.9 ms |
| p95 | < 600 ms | 5.0 ms |
| p99 | < 1 s | 256 ms |
Steady-state reads are cache hits (~3 ms); the p99 tail is the occasional MBTA proxy refresh, still well inside the 1 s budget, with 0 failed requests. (This is proxy-cache mode; the Kafka hot cache, on in-cluster, removes the upstream refresh from the common path entirely.)
Every service emits OpenTelemetry traces and metrics. A Grafana dashboard tracks end-to-end delay-notification latency (p50/p95/p99), transit-api read latency (p50/p95/p99), Kafka consumer lag, Bedrock invocation latency, and MBTA request rate against the rate-limit ceiling; SLO breaches (tail latency, growing consumer lag) route through Alertmanager. Definitions live in platform/observability.

One of the dashboard's panels: transit-api read latency under load, with p99 in the low hundreds of milliseconds and p50/p95 sub-millisecond (cache hits), comfortably inside the 1 s SLO.
MBTALive/ # iOS app sources (SwiftUI): arrivals, commute, insights, services
MBTAWidget/ # WidgetKit + ActivityKit extension
MBTALive.xcodeproj # Xcode project
mbtalive-platform/ # cloud-native backend platform
services/ # transit-api (BFF), ingestion, delay-detector, sink-writer,
# insights-api, account-service, llm-gateway,
# commute-notifier, apns-pusher
terraform/ # substrate (EKS / GKE / kind) + platform modules
argocd/ # ArgoCD Application manifests (GitOps)
platform/ # Kafka topics, observability dashboards + SLO alerts
docs/ # architecture diagram, performance SLOs, load test
scripts/dev-up.sh # run the core backend locally
Prerequisites: Xcode (iOS 26 simulator), Go 1.25+, Docker Desktop (for local Postgres), and an MBTA V3 API key in Secrets.xcconfig (copy Secrets.xcconfig.example).
Run the core backend locally (transit-api, insights-api, account-service + Postgres):
mbtalive-platform/scripts/dev-up.shThen open MBTALive.xcodeproj in Xcode and run on the iOS simulator. The app defaults to the local services (transit-api on :8082), so live arrivals work out of the box; the full platform (Kafka, analytics, notifications) runs on Kubernetes via Terraform + ArgoCD.
The app talks only to the platform's owned /v1 contract, never MBTA directly. The read API's full schema is in transit-api/openapi.yaml; the surface at a glance:
transit-api: live read path (Backend-for-Frontend)
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/v1/predictions |
Upcoming real-time arrivals for a stop |
GET |
/v1/predictions/stream |
Live updates as owned SSE events (reset / add / update / remove) |
GET |
/v1/schedules |
Published timetable for a stop |
GET |
/v1/alerts |
Active service alerts for a stop |
GET |
/v1/routes |
Subway routes (light + heavy rail) |
GET |
/v1/stops |
Stops for a route, or all subway stops |
GET |
/v1/route-directions |
A route's two terminal names |
insights-api: analytics read path
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/v1/on-time-performance |
Per-route on-time trends from stored history |
GET |
/v1/insights-summary |
Plain-English AI summary of recent performance |
GET |
/v1/delay-events |
Recently detected delay events |
account-service: identity & commute sync
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/v1/auth/apple, /v1/auth/google |
Sign in with Apple / Google |
POST |
/v1/auth/refresh, /v1/auth/logout |
Rotate / revoke the session |
GET POST |
/v1/commutes |
List or create saved commutes |
PUT DELETE |
/v1/commutes/{id} |
Update or remove a commute |
The backend's business logic is unit-tested, with the emphasis on the parts where correctness matters (delay detection, pattern/cooldown logic, commute matching, wire parsing, caching, the LLM provider factory), not glue code.
- Go services: table-driven tests across
transit-api,ingestion,delay-detector,sink-writer,insights-api,account-service, andapns-pusher. - Python services:
pytestforllm-gateway(provider factory, Bedrock / Gemini / template providers, pattern tracker, wire) andcommute-notifier(matcher, enrich, pipeline, cache, wire). - Load / SLO: k6 drives the read path and fails if the latency SLO is missed.
- CI: GitHub Actions runs
go vet+go test(Go) andpython -m pytest(llm-gatewayandcommute-notifier) on each change, before images are signed and digest-pinned.
# Go service
cd mbtalive-platform/services/delay-detector && go test ./...
# Python service
cd mbtalive-platform/services/llm-gateway && python -m pytest
# Load test (against a running transit-api on :8082)
BASE_URL=http://localhost:8082 k6 run mbtalive-platform/docs/loadtest/predictions.jsMIT. See LICENSE for details.






