Solo's mobile app streams a GPS point to our backend every few seconds while someone is driving. For years, those points landed inside our main Rails application — one worker thread tied up per incoming point, each one waiting almost entirely on a fast in-memory database in Redis rather than doing any real computation. That's a wasteful pairing: dedicating a whole thread to sit and wait is expensive at scale, and this one piece of the app does it hundreds of millions of times a day. Over June and July, we pulled this one piece out of the monolith, rebuilt it as a small, dedicated service written in Go, and rolled it into production gradually rather than switching it on all at once.
This post walks through why we made that call, how we tested it safely in production before fully committing to it, what actually went wrong along the way, and — with real numbers pulled from our own monitoring, not estimates — what changed as a result.
The approach
The key decision was to change as little as possible. GPS processing is really two very different jobs: writing new location points as they arrive, and reading that location history back out later, for trip breakdown, mileage, trip routes, dashboards and other features that depend on where a user has been. We only touched the writing side. Every part of the system that reads location data back out — mileage calculation, dashboards, analytics, other backend jobs — was left completely untouched.
That was possible because the new service writes into the exact same underlying data store, in the exact same shape, that the old code used. Nothing downstream needed to know a location point came from a different program. To everything else in our system, a GPS point looks identical whether the old code wrote it or the new code did — so we could swap out how the data gets in, without touching a single line of the much larger, much more complex code that reads it back out. That's the leverage: change the narrow, high-volume path; leave the wide, already-working one alone.
Making the two writers indistinguishable took real care in two specific places:
- How we encode a location. We store GPS points using a well-known technique called geohashing, which converts a latitude/longitude pair into a single compact code — designed so that looking up "who was near this spot recently" later is fast. Both the old code and the new code feed that same code into the same lookup tables, so they have to produce byte-for-byte the identical result for the same coordinate, or the two systems would encode the same real-world location differently and downstream lookups would quietly return wrong or missing results. We didn't just write the new version and eyeball it — we generated a large set of known-correct answers straight from the original code, and the new version is checked against every one of them automatically, every time either side changes. If a future edit would ever produce a different code for the same input, that gets caught immediately, instead of quietly writing bad location data somewhere.
- How we recognize a logged-in user. When someone logs in, our backend issues their app a token — a piece of proof that says, in effect, "this is user #12345, logged in until such-and-such time." To stop anyone from forging a fake one, that token is stamped using a private piece of data that only our backend knows (never sent to the app, never visible to the user); checking a token later means re-doing that same stamping and confirming it still matches. Before accepting a location point, the backend checks that stamp, plus a fast lookup against a marker in Redis confirming the token hasn't since been revoked. That checking logic already existed. The new service had to check the exact same stamp, against the exact same marker, using that exact same private piece of data — not a new or separate one of its own — so that someone who logged in through the (unchanged) login flow would be recognized as valid the instant their app started sending location pings to the new service, with nothing for the end user to notice, and certainly no forced re-login.
One more piece worth calling out: the first GPS point of the day triggers a small downstream job (flagging that today's first location update happened). The old code enqueued that job with a single line, using a client library that hands the job straight to the job queue's own storage. The new service, written in a different programming language, doesn't have that same convenient library available. The easy way out would have been to add a new web endpoint back on the old side, and have the new service call that over the network to enqueue the job on its behalf. We deliberately avoided that: it adds an extra network call to the busiest path in the whole app for no real benefit, and worse, it would make a fundamentally simple write depend on the old application's web server being up and running, when the only thing actually needed is the job queue's storage itself. It also would have meant anyone testing the new service on their own machine would first need to stand up the entire old application just to exercise something that's really just a write to a shared store. Instead, we taught the new service to write a job directly into that shared storage, in the exact format the job queue expects to read — no dependency on the old application at all.
Operational methodology: rolling out traffic gradually, not flipping a switch
The centerpiece of how we tested this safely is a piece of infrastructure called a load balancer. Every request from the mobile app already passes through one on its way to our servers — its job is to receive incoming traffic and decide which pool of backend servers should actually handle each request. Normally that's a simple decision: one pool, one set of servers, done.
A load balancer can also be configured to split incoming requests between two different pools of servers, by percentage — send, say, 90% of requests to pool A and 10% to pool B. That capability is what made this rollout possible. We stood up the new service as a second pool, sitting right alongside the existing one, behind the very same load balancer our users were already talking to — same address, same security, completely invisible to the app itself. Then we dialed the percentage of live, real, production location-tracking traffic sent to the new pool up in stages — 0%, then 10%, then 50%, then 100% — watching error rates and response times closely at every step before moving on to the next one.
That gradual dial is the entire safety mechanism, and it's worth sitting with for a moment, because it's really the whole strategy. At 0%, we're only proving the new service is up and reachable — no real user is touched by it yet. At 10%, a small, deliberately bounded slice of real traffic starts hitting the new code; small enough that if something's wrong, the blast radius is small and the problem is easy to spot. By the time we reach 100%, we've already watched the new service handle a growing share of real, unpredictable production traffic, under real conditions, for days — not guessed at how it might behave from a staging environment that never quite matches reality. And at any point along the way, undoing it is a single number: turn the percentage back down to 0%, and every request goes back to the pool that was already working, instantly, with nothing else to change or clean up. That's what let us treat this as routine, low-stakes work, rather than a scheduled, all-hands cutover event.
Two more things made it safe to run this loosely rather than as a white-glove event:
- Watching for real data loss, not just crashes. Because the new service is designed to tell the phone "success" even in the rare case that an internal save failed (matching the old behavior — see above), simply watching for error responses wouldn't have told us if location points were quietly going missing; everything would have looked perfectly healthy from the outside. So we added a separate alert that watches specifically for the internal signal that a save genuinely failed, deliberately ignoring the common, harmless case of a phone just disconnecting mid-request, so it only pages someone when a location point may actually have been lost.
- Only paying for detailed logging when we could afford it. Because this endpoint is called so often — hundreds of millions of times a day — logging details about every single request was never something we could do on the old side; the sheer volume would have made the logging bill for this one piece of the app alone prohibitively expensive, so the old code never logged anything about individual requests. But the moment we started sending a small percentage of real traffic to the new service, we needed exactly that kind of visibility — a way to trace individual points and be certain none were quietly getting lost in the switch. At low traffic percentages, that same detailed logging was cheap enough to justify, so we turned it on. As we grew more confident and increased the percentage of traffic going to the new service, we deliberately turned it back off again — because once we were also handling full production volume, keeping it on would have recreated the exact cost problem the old side always avoided.
What broke on the way
None of this was a clean cutover. In order:
- A silent way to lose data that only showed up under real mobile conditions. Mobile networks are unreliable — phones lose signal, apps get backgrounded, connections drop mid-request, constantly. Our old code was written in a way that, once it started processing a request, it finished the work even if the phone on the other end had already disconnected — the location point still got saved. The new service, written using the standard way Go handles web requests, did the opposite by default: the moment it noticed the connection was gone, it immediately abandoned whatever it was doing, including a save to Redis that hadn't finished yet. That's a perfectly reasonable default in most situations — why keep working for a client that's no longer listening — but for us it meant a real GPS point could quietly disappear any time a phone's connection blipped mid-request, which on cellular networks is often. The fix was to explicitly tell the service: for this specific piece of work, finish it regardless of whether the client is still there, but never let it run forever — cap it at a firm time limit, so a genuinely stuck save still gets abandoned rather than hanging indefinitely. That matched what the old code had been doing all along, without reintroducing the risk of a request that never ends.
- Chasing a timeout that wasn't the actual problem. Early on, a small share of requests were taking multiple seconds to complete — far slower than normal. The most obvious suspect was the login-check step: it's the one part of handling a request that reaches out over the network to Redis, and a network call is naturally where you look first for network-shaped slowness. So we added a strict time limit there, on the theory that if this check was ever the culprit, we wanted it to fail fast rather than let it drag a request out for seconds. But once we added detailed, per-step timing to a request — so we could see exactly how long each internal step was taking, not just the request as a whole — the numbers showed the login check was never the problem at all: it was consistently finishing in under a millisecond, every single time. The real slowness turned out to be something else entirely — the time spent simply waiting to receive the full request from a slow mobile connection, which has nothing to do with our backend at all. Once we knew that, we relaxed the login-check limit back up, since it was never actually risky, and a limit that tight was itself a hazard — capable of failing a perfectly healthy check on nothing more than an ordinary, brief network hiccup. In its place, we added a separate limit on how long we'd wait to receive a request's data in the first place, which is what was actually needed.
- A bigger connection pool made things worse, not better. Our service keeps a small pool of already-open connections to Redis ready to go, so it doesn't have to pay the cost of opening a brand-new connection for every single incoming request — reusing a warm connection is much cheaper than establishing a new one each time. It's tempting to assume a bigger pool is simply better: more open connections should mean more work can happen at once. But Redis itself is one system with a finite amount of work it can actually do at any given moment — like a single checkout counter, where however many customers are waiting in line, only so many can be served in parallel, no matter how many people are in line. Handing that one counter an enormous pool of open connections doesn't create more real capacity to serve them; it just means more requests are competing for the same limited processing power at once, plus the connections themselves add memory and bookkeeping overhead on both sides. Past a certain size, a larger pool wasn't relieving pressure on Redis, it was adding overhead of its own — so we deliberately shrank it back down (from 200 connections to 64), which reduced the contention rather than the throughput.
- A new program that thought it had more computer than it actually did. When you run a service in the cloud, it doesn't get an entire physical machine to itself — it's given a specific, limited slice of a much bigger shared machine's processing power (in our case, the equivalent of 2 processor cores), enforced by a hard limit called a CPU quota, even though the actual hardware underneath might have dozens of cores available. The Go language spreads a program's work across multiple internal "lanes" running in parallel — controlled by a setting called
GOMAXPROCS— and by default it decides how many lanes to open based on how many processor cores it believes it has access to. The version of Go we started with wasn't aware of the cloud-specific CPU quota our container had actually been given — it read the full underlying machine's core count instead, and setGOMAXPROCSfar higher than our 2-core allocation could realistically run at once. That's a bit like scheduling a shift of thirty people to share two desks: everyone still shows up, but they spend more time fighting over the desks than getting anything done. Upgrading to Go 1.25 — whose runtime correctly reads the container's actual CPU quota instead of the whole machine's core count — fixed this by settingGOMAXPROCSto match what we'd genuinely been given. - Our capacity planning was a little too optimistic. Our infrastructure automatically adds more running copies of the service whenever traffic per copy crosses a set threshold — that's the actual mechanism behind autoscaling: a target requests-per-second number baked into the scaling policy, which the system watches to decide when it's time to spin up another copy. We originally set that threshold at 1,500 requests per second per copy. A real evening traffic peak on July 8 — the same day we moved fully over to the new service in production — showed per-copy latency actually starting to degrade closer to 1,300 requests per second (the bottleneck was concurrent round-trips to Redis, not raw CPU). So we lowered the autoscaling threshold itself to 1,000 requests per second per copy, giving ourselves comfortable headroom below where we'd actually seen trouble start, rather than tuning it to sit right at that edge.
- Deciding, deliberately, when it's OK to give up on a write — and never running just one copy. We built in what we call a write budget: a hard one-second ceiling, enforced as a request deadline in code, on how long the service will keep trying to save a single location point to Redis. If Redis is ever briefly overloaded and a particular save is taking unusually long, the service abandons that one save the instant the deadline hits, rather than let the request hang indefinitely — since a new GPS point arrives from the same phone every few seconds anyway, occasionally shedding one under rare, extreme load is a far better trade than a request that never finishes. Separately, our infrastructure is configured with a hard minimum of two running copies of the service at all times (
autoscale_min = 2), even at the quietest, lowest-traffic moments — never just one. With only one copy running, anything that takes it down even briefly, like a routine restart during a deploy, would momentarily stop the entire mobile fleet from being able to record its location at all. Running two means the load balancer always has a working copy to send traffic to, even while the other is restarting.
The new state — real numbers, not estimates
Both the old and new code paths sit behind that same load balancer described above, which happens to record exactly how long every request takes. That gives us a genuinely fair, apples-to-apples comparison — not an estimate, not a lab benchmark, but real production response times, before and after, off the same infrastructure.
Three numbers matter here, and they're worth defining plainly:
- p50 — the typical request. Half of all requests were faster than this, half were slower.
- p90 — the slower-than-usual request. Only 10% of requests were slower than this.
- p99 — the worst request that still counts as normal. Only 1% of requests were slower than this — but that 1% is exactly where a real person starts to notice something feels off.
| p50 | p90 | p99 | |
|---|---|---|---|
| Before — old code, geo traffic included (avg, Jun 18 – Jul 3) | 40 ms | 269 ms | 467 ms |
| After — old code, geo traffic removed (avg, Jul 20 – Aug 9) | 35 ms | 69 ms | 384 ms |
| After — new dedicated Go service (avg, Jul 20 – Aug 9) | 11 ms | 33 ms | 98 ms |
The "before" row reflects everything that pool of servers was handling at the time, not a geo-only number — we don't have a breakdown that granular going back that far. But GPS tracking was by far the highest-volume thing that pool handled (the mobile app pings it every few seconds, for every active driver), so it dominates that mix — and the shape of the change lines up exactly with the rollout: the drop starts July 7–8, the day the new service went to 100% of traffic in production, and it happens as a clean step, not a gradual trend.
That step tells two different stories at once:
- The geo path itself got much faster. The typical request time dropped by about 3.6×, the slower-than-usual tail dropped by about 8×, and the worst-case tail dropped by about 4.8×, once it moved off the old shared pool of workers and onto its own dedicated service.
- Everything else got faster too, just from geo leaving. Even looking only at the traffic that stayed on the old code, its own slower-than-usual tail dropped by roughly 4× (269ms → 69ms) the moment geo traffic left. That tells us the geo write path, at that volume, had quietly been eating into the limited pool of workers available to handle every other kind of request on that side of the system, the whole time. Carving it out was as much a win for the old application as it was for the new one.
On footprint: this one endpoint carries the large majority of all traffic our backend receives — every active driver's phone pings it every few seconds, continuously, all day — and today that runs on a small, automatically-scaling fleet of just 2 to 8 lightweight cloud servers, each given a modest, fixed slice of processing power. Before the migration, the equivalent slice of that same traffic rode along inside the old application's much larger server fleet. And this isn't just a theoretical before-and-after: watching that fleet's own running server count over time, its floor held at a steady 29 servers, every single day, through July 7 — then dropped to a hard floor of 20 servers exactly on July 8, the same day the new service went fully live in production — and it's stayed there, flat, for the month since. The daily peak it used to burst up to under load shrank as well, from the 40s–60s down to the high 20s. That's the fleet visibly getting smaller in production, in lockstep with the cutover — not a config value nobody ever revisits.
That difference in footprint shows up directly on the bill — and it's worth pulling real numbers here rather than hand-waving "cheaper." Both fleets happen to run on the exact same family of processor: Amazon's own ARM-based "Graviton" chips. The old web tier runs on m6g.large instances (2 processor cores, 8 GB of memory each); the new geo service runs on Fargate tasks sized to roughly half of that (2 processor cores, 4 GB of memory each). So this is a genuinely fair, same-hardware-family comparison — not an old-generation-chip-vs-new-generation-chip trick. At AWS's public on-demand pricing for those exact instance and task types, in the region we run in: a single m6g.large costs about $56/month; a single geo task, on Fargate, costs about $58/month — nearly identical per-unit pricing, since Fargate carries a small convenience premium over renting the same hardware directly and managing it yourself.
Put the real before/after server counts against those prices, and the old web tier's own bill dropped on its own — before counting the new service at all. 29 always-on servers at ~$56/month each comes to roughly $1,630/month; 20 comes to roughly $1,124/month — a real, measured cut of about $506/month, or 31%, just from the web tier needing fewer machines once geo traffic left it. That's not free money, of course — the new geo service itself now costs something where it used to run as part of the same shared fleet: at its own baseline of 2 tasks, roughly $115/month. Net it out, and the combined baseline bill for both systems together still dropped by roughly $391/month — about 24% — even after fully paying for the new dedicated service on top. Scaled up to a full traffic peak (the old tier's configured ceiling of up to 90 servers, against the new service's up to 8 tasks), the same shape holds: roughly $5,060/month against roughly $460/month for the geo side alone. As with the server-count comparison above, that peak-side figure stacks the entire web tier's bill (which does far more than just geo) against the entire geo service's bill, not a precise per-endpoint attribution — but the baseline number is a real, measured before-and-after on the exact same fleet, and it moved in exactly the direction you'd hope.
Takeaways
The riskiest part of a project like this was never really the new code itself — making sure it produced identical output to the old code, byte for byte, was a problem we solved once, upfront, with automated checks generated straight from the original production code. Every actual incident came from somewhere else entirely: subtle differences in how two different pieces of technology behave by default, under real conditions, that simply don't show up until you're handling real production traffic — one system finishing a job after a client disconnects and the other not, a resource pool that helps up to a point and then starts hurting, a program that doesn't realize how much computer it's actually been given. Rolling traffic over gradually, behind the same load balancer, with an instant one-number rollback, is what let us find every one of those in production, at a small, safe percentage of traffic, instead of all at once in a single high-stakes cutover — and monitoring that didn't simply trust "the request succeeded" as proof the data was actually saved is what would have caught it immediately if any of them had actually cost us real location data.



