
Building a Modern Transit Website: Live Bus Tracking, a System Map, and a Stop Finder on Open Data
When we rebuilt the Corpus Christi Regional Transportation Authority's website on SvelteKit, the flagship features were the ones riders touch every day: every vehicle in the fleet moving live on a map, a system map of the whole network, a stop finder that works three different ways, and a real-time departure board for every stop in the system. We told the business side of that story in the CCRTA customer story. This post is the engineering companion: how a real-time transit site runs on a static-first, serverless architecture with no always-on server, and why the whole thing costs almost nothing to operate.
The Constraints That Shaped Everything
Two constraints set the shape of this build.
The first is economy. CCRTA maintains the lowest fares of any transit system in the United States. An agency that prices rides that way watches every operating dollar, and turnkey vehicle-tracking products are priced for agencies that do not. Keeping costs low was not a nice-to-have on this project. It was the brief.
The second is security. The rebuild's security posture depends on there being nothing to attack: prerendered pages, serverless functions, no persistent server. A bus tracker seems to fight both constraints at once. Live positions update every few seconds, riders poll continuously, and the upstream data source is a vendor feed that was never meant to serve the public directly.
The resolution is an old idea applied strictly: decode once, cache briefly, share widely. The agency's AVL vendor publishes two GTFS-Realtime feeds as Protocol Buffers: vehicle positions and trip updates. A serverless endpoint decodes the position feed, reduces it to a compact JSON array, and stores it in Redis for 10 seconds. In front of that sits a 10-second edge cache (s-maxage=10), so concurrent riders share one response. The browser polls every 15 seconds and pauses when the tab is hidden.
Follow the arithmetic: no matter how many riders are watching, the vendor feed is hit a few times a minute, total. Positions are never more than a few seconds staler than the polling interval a dedicated server would give you, and there is no dedicated server. When anything upstream fails (the feed times out, the protobuf is malformed, Redis hiccups) every layer degrades to an empty result instead of throwing, so the map quietly shows the last known state rather than an error.
Two Feeds That Do Not Quite Agree
The interesting engineering is in the seams. The vehicle-position feed sometimes omits the route a bus is running; the trip-update feed knows, but it is a separate document on a separate cadence (we cache it for 25 seconds). So the serve path joins the two: positions get their missing route IDs back-filled from trip updates, and each vehicle picks up a predicted arrival time for its next stop from the same join. The feeds share trip, stop, and route identifiers, so buses, stops, and schedule rows all resolve to each other without a mapping table.
The same trip-update feed yields one more thing for free: closed stops. GTFS-Realtime has a free-text alerts feed, but parsing prose for closures is guesswork. Instead we derive it structurally: a stop that every current trip is marked to skip, and that no trip serves, is closed, and renders as a red dot. No text matching, no false positives from an alert about next month.
One Component, Three Pages
All of the map surfaces (the bus tracker, the system map, the per-stop mini-maps) are a single Svelte 5 component: BusMap.svelte, 1,029 lines, which exposes an imperative API (trackVehicle, focusStopByCode, setRoute, recenter) through exported functions. Each page composes the same component with a different framing strategy, and deep links like ?track=<vehicle>&stop=<code> reconstruct a specific view once the map is ready and the first poll has landed.
Rendering sits on the Google Maps vector basemap with custom DOM markers. Each bus is an arrow rotated to its GPS heading, with the route badge counter-rotated inside so the number stays upright while the arrow points down the street, colored from the GTFS route palette. We deliberately do not interpolate positions between polls; markers are reconciled (moved, added, removed) each cycle. What gets the smoothing budget instead is the camera: auto-centering reframes to visible buses on every poll, but a programmaticMove flag distinguishes the app's own camera moves from the rider's, and the instant a rider pans or zooms, auto-centering gets out of the way.
The system map is the same component wearing a different hat: the full route network drawn as polylines from GTFS geometry, with live buses off by default. Stop dots are viewport-culled, only rendered at high zoom and only inside the current bounds, so the map places a few dozen markers at a time instead of the entire system.
Finding a Stop Three Ways
Rider search has three modes, and each hides one real-world lesson:
- By stop number. GTFS stop codes are zero-padded ("0009") but the sign at the stop says 9, and that is what riders type. Search matches against both forms and ranks exact numeric matches first so they survive the result cap.
- By address. Google Places autocomplete, constrained to a bounding box computed at runtime from the stop coordinates themselves, then filtered by a haversine check that rejects any pick more than a few miles from the nearest actual stop. The bounding box alone would happily suggest addresses in the middle of the bay.
- Near me. Browser geolocation with a watchdog timer, because some mobile browsers simply never fire the callback, and a spinner that never resolves is worse than a graceful fallback to search.
Departure Boards That Fail Toward the Schedule
Every stop page has a live departure board. Scheduled times live in a Turso (SQLite) database, loaded from the static GTFS timetables, and the live feed overlays status on top: on time, late, early, canceled, skipped. When the realtime feed is down, the boards fall back to the printed schedule instead of going blank, which is exactly what a rider standing at a pole needs. Times are formatted timezone-correctly for America/Chicago, including GTFS's charming convention that a 1 a.m. trip on a service day is written as hour 25.
The Part Nobody Sees
None of this parses GTFS at runtime. The static feed (thousands of stops, hundreds of thousands of timetable rows) is processed by a daily CI job into about a megabyte of committed JSON, and the serverless runtime only ever reads that. The pipeline has enough of its own stories (a from-scratch CSV parser, a geometry heuristic, and a diffing trick that keeps printed QR codes from ever hitting a 404) that we gave it its own post.
The result: a transit map that feels live, an upstream feed touched a few times a minute, and a runtime bill that rounds to zero, which is the right bill for an agency that keeps its fares the lowest in the country. Real time and static-first turn out not to be opposites. They compose, as long as you put the caches in the right two places.