Skip to main content
Lightning Jar - Web Studio Lightning Jar Wordmark

The Maps Bill That Wasn't Riders

7/28/2026
A watercolor painting of a pink piggy bank on a cream background

The Maps Bill That Wasn't Riders

We build and maintain ccrta.org, the Corpus Christi Regional Transportation Authority's website, including a live bus tracker riders use thousands of times a day. In late July the Google Cloud bill said the tracker was suddenly on pace to cost about $1,440 a month in Maps fees, and climbing. Two days later the forecast was $182, and the story of the difference is mostly a story about measurement.

The billing model shapes everything

Google bills the Maps JavaScript API per map instantiation: $7 per 1,000 new google.maps.Map() calls after 10,000 free each month. Everything after that moment is free. Panning, zooming, recentering, the 15-second vehicle poll: none of it costs anything. A page that creates a map costs $0.007 whether the rider uses it for two seconds or an hour.

Four of our pages created a map on mount. So every page view of the bus tracker, the stop finder, the system map, or a route page cost money before anyone touched anything. That is a perfectly normal way to build a map page, and it means your Maps bill is a page-view counter wearing a trench coat.

Which matters, because the page views were not what they looked like.

Thirteen point nine pages per visitor

The analytics for one week showed 66,971 page views from 4,804 visitors: 13.9 pages per visitor, which is not how humans browse a transit site. Splitting by browser found the anomaly immediately. A segment reporting itself as a generic in-app webview accounted for 49,180 of those views from 579 visitors, 85 views each. Narrowing to its desktop-Linux slice isolated the client exactly: 48,101 page views from 314 visitors, 153 views each, entirely US-based.

CCRTA's own rider research says real visits are 93% mobile. On the bus tracker, "desktop" visitors averaged 92 page views apiece while mobile visitors averaged 3.4. Desktop traffic on the map pages was overwhelmingly automated; mobile was overwhelmingly riders.

We killed the innocent explanations one at a time. A remount bug re-instantiating maps on tab switches: a 75-second scripted session produced exactly one billable load, ruled out. Synthetic pageview events: these were real page loads. A crude bot with a flat traffic curve: no, the hourly distribution ramped at 4 a.m. and tapered at 8 p.m., tracking bus service hours. This client keeps human hours, which is why simple heuristics never flagged it.

The proof that finally didn't depend on analytics segmentation came from a calendar. Sunday's Dynamic Maps spend was $46.99 and Monday's was $48.44, a 3% difference, while Sunday runs 54% of weekday bus service. Rider-driven load cannot be flat across that gap. A load that ignores the day of the week is not made of riders. That one comparison needed two numbers off a billing screen, was available on day one, and is the first thing we will reach for next time.

The firewall lost, and it didn't matter

The obvious move is to block the bot, so we tried. Flipping Vercel's bot protection from logging to an active JavaScript challenge changed nothing: the client's request rate was identical in matched hours before and after. The same challenge does stop curl and default headless browsers cold, so whatever this is, it is better dressed than that.

That result set the design constraint for everything after it. Any fix resting on classifying the client is a bet against an adversary who had already won round one. The durable move is different: stop making a page load a billable event. A scraper that loads a page and leaves costs nothing if the map only appears for a verified human, and that works whether or not you ever learn what the scraper is.

So map pages became static-first. On load you get a cached map image, generated server-side and shared across visitors. The interactive Google map mounts only after a gate endpoint grants a session: a bot-detection verdict, a per-session frequency cap, and a global daily budget, all of which must agree. The budget lives in Redis, counted per service day, and doubles as our own instrument: a one-query count of billable map loads that does not wait on Google's reporting lag.

Two design decisions did most of the work. First, the gate fails open at every step. A verdict error, a Redis outage, a missing signal: all of them yield the interactive map. A bug in this system costs us money; it never takes the map away from a rider standing at a bus stop. Second, activation keys on the input device, not the user agent. Touch devices, which is to say riders, hydrate the map immediately on mount. Desktop waits for the first mouse movement, something a person produces within milliseconds of caring about a page and a headless client typically never produces at all. The check is CSS's pointer: coarse media query, a description of the physical hardware rather than a string anyone can set. Real desktop riders wait roughly zero; the client averaging 92 desktop page views per visitor gets a nice cached picture of Corpus Christi.

Oops, Our Mistake

One decision in the middle of this went badly, and it might be the most valuable paragraph in this article. We briefly enabled a deeper bot-analysis mode and put its verdict in front of the map load sequence, without measuring time-to-map on a real phone. On a real phone it was about 20 seconds, up from half a second. The verdict fetch sat in front of everything, the vendor's challenge script is heavy on mobile, and the timeout we had wrapped around it did not actually bound it. We had verified that the map appears and that it costs less, and never verified how fast, on the device 93% of riders use, on the agency's most-used page. Correctness and cost are not the whole of "it works." The mode was reverted the same day; the simple verdict path holds the line at about half a second.

Did it actually work?

The trap in any bot mitigation is that the cost graph falls and you cannot tell whether you fixed something or the bot got bored. So the test we designed for was divergence: if the fix works, page traffic should stay flat while billable map loads collapse. In a three-hour production window the bus tracker served 349 page requests, slightly above its pre-sprint rate, while the gate endpoint was consulted 104 times across all four map pages combined. Most page loads now arrive, never produce a human signal, and cost nothing. Traffic flat, hydrations collapsed. The bot is still out there, still keeping business hours, still getting its picture.

The bill agrees. Like-for-like Sunday went from $46.99 to $6.74, and the matching weekday from $48.44 to $7.08, both about 86% down. Better, the before-and-after arithmetic lets you solve for the scraper twice, from two independent day pairs, and the estimates land within 2.7% of each other: roughly 5,800 automated map loads a day, about 85% of everything we had been paying for. When two estimates from different weeks and different ridership levels agree that closely, the modelled thing is real.

Steady state is now about 1,000 rider map loads a day, roughly $182 a month at list pricing, and less on the subscription tier the measured volume justified. One more finding for the transit crowd: rider tracker usage is nearly identical on Sundays and weekdays, 963 against 1,011 loads, despite Sunday running half the service. Sparser service plausibly makes the tracker more necessary, not less. Service level does not predict app usage.

Other Discoveries

Instrumenting a production system for two days surfaces things nobody was looking for. Chasing an unrelated symptom, we found our AI translation backend had been silently dead for 14 days behind an exhausted spend cap, invisible because translation is cache-first with a 90-day TTL and fails open. Cached strings kept serving, pages stayed 88% translated, and the smoke test honestly reported "mostly fine." A cache-first system with a dead backend looks healthy for exactly as long as the cache lasts, and anything that fails open needs a health signal that does not depend on the output looking wrong. We also fixed a homepage crash on iOS 15 (a date string one lenient parser accepted and Safari 15 did not) and a two-year-old CSS line that turned every navigation's scroll-to-top into a 1.25-second animation.

Six production deploys, tests up from 150 to 226, and a Maps bill an order of magnitude smaller, all without ever learning the bot's name. It never needed one. It just needed to stop being a customer.

headshot of Kevin Peckham
Kevin Peckham
Principal, Lightning Jar