Ramiz RajaBook a call
← Writing
The App-Rescue Playbook

A Correct Fix That Quietly Broke the Feature Next to It

The dangerous bugs aren't in the code you're editing. They're in the code you're not looking at — the feature that still compiles, still passes its own screen, and silently does less than it did last week.

Ramiz RajaAugust 15, 20265 min read
A Correct Fix That Quietly Broke the Feature Next to It

A change went in that did exactly what it was supposed to. A unit of work that half-finished — its main operation succeeded, a smaller follow-on step didn't — used to be recorded as a clean success. After the change it was recorded as what it actually was: a failure. More honest, plainly correct, the kind of fix you don't think twice about. It shipped in the same afternoon it was written.

Before the day was out it had to be fixed again, because that honest little correction had quietly taught the app to give up on entire batches of work.

Nothing crashed. Nothing threw. The build was green, the feature's own screen looked identical, and every automated check that existed the day before still passed. The app just silently started doing less than it did the day before — and that gap between "still runs" and "still works" is where the most expensive class of mobile bug lives.

If you ship software, you have written a fix like this — correct, obvious, the kind you don't think twice about — and had it quietly break something three files away that you weren't looking at. This is one of those in full: how it happened, why it stayed invisible, and the cheap thing that now catches the next one before a user does.

The bug your own progress creates

A regression is the feature that worked last release, still compiles this release, and stopped doing its job somewhere in between — usually as a side effect of a change aimed at something else. What makes it expensive is that nobody is watching the thing that used to just work. A batch that stops halfway through doesn't file a ticket; the work that never happened doesn't announce itself. There's no crash rate, no error surface, no dashboard line — the number that would show the loss is a number nobody is graphing. The app keeps its reputation for reliability right up until enough people quietly notice it hasn't earned it lately. Here's how this one worked.

One failure, two counters

The feature is a worker that processes a batch of queued items, one at a time. It has a circuit breaker: after a handful of consecutive failures it stops trying the rest of the batch, on the theory that a pipeline failing over and over is broken and shouldn't burn through every remaining item proving it. Good instinct. Also where the trouble came in.

The code keeps two counters. One is honest per-item bookkeeping; the other is the signal the circuit breaker reads to decide the whole pipeline is broken. The honesty fix made a partial failure — main operation done, follow-on step failed — "count as a failure." Correct in spirit. But counting it ticked both counters, the breaker's included — and a follow-on-step failure is no evidence the pipeline itself is broken. It had no business touching that second one:

// Illustrative of the pattern — identifiers renamed.
failureCount++          // "this item's outcome was a failure"    — bookkeeping for one item
consecutiveFailures++    // "the pipeline is broken, stop the batch" — a verdict over every item still queued

The claim this illustrates: the two counters answer different questions. failureCount is per-item accounting; consecutiveFailures decides the fate of every item still in line. A follow-on-step failure should tick the first and never the second.

Ticking the breaker's counter for that failure class meant several follow-on-step failures in a row — on items whose main operation had succeeded every time — tripped the breaker and skipped everyone left in the batch:

With the regressionAfter the repair
A follow-on-step-only failureticks failureCount and consecutiveFailuresticks failureCount only
Three in a rowbreaker trips → the rest of the batch is skipped, never attemptedbreaker never trips → every item still attempted
A later item's main operationnever runs, marked skipped-by-breakerruns, with its own honest result

The claim this illustrates: a follow-on-step failure must stay an honest per-item failure without ever stopping the primary work of the items behind it.

The repair was the removal of a single line — stop ticking the breaker counter for that failure class, keep the honest one. But the line that mattered more wasn't in the repair. It was in the test that shipped with it.

A regression test is a test of the failure, not the fix

The change that keeps this bug from silently coming back is a single test that reproduces the exact thing that broke. Take four queued items whose follow-on step fails but whose main operation succeeds, against a breaker that trips after three consecutive failures — then assert what must be true afterward:

// Illustrative of the real test (identifiers renamed).
// Fixture: four items, each with a successful main op but a failing follow-on step.
verify(worker, times(4)).process(any())                    // all four attempted — none skipped
assertTrue(results.none { it.reason == BREAKER_SKIPPED })  // the breaker never tripped
assertEquals(4, results.count { it.status == FAILED })     // all four recorded as failed

The claim this illustrates: FAILED here is the item's overall outcome — a half-done unit of work is not a success, even though its main operation landed. By construction these assertions fail on the old code — the breaker trips on the third consecutive follow-on failure, so the fourth item is skipped and only three are ever attempted — and pass on the new, where all four run. That red-to-green flip on the same fixture is the entire point of a regression test.

That is the trick, and it's worth being precise about: a regression test is not a test of the fix. It's a test of the failure. Its value is the red-to-green flip — it pins the specific behaviour that broke so the codebase remembers it, in a way no person on the team has to. A test written merely to confirm the fix "works" would have missed the point; this one was written to make the failure hard to reintroduce by accident.

Why it surfaced fast — and why it now stays caught

Two different things, worth separating. The first occurrence wasn't caught by a test at all — no test covered that interaction yet, which is exactly why every existing check stayed green. It surfaced fast for a duller reason: the change was exercised on a real batch soon after it shipped, and a batch quietly processing fewer items than it was handed is a shortfall you notice within an afternoon if you're actually running the thing, rather than weeks later. Fast, but luck-shaped — it depended on someone looking.

What makes the return of this bug non-silent is where that new test now lives. The local release gate runs its stages in a fixed order and fails fast — the moment a cheap stage goes red, the expensive ones downstream are aborted rather than run:

# Illustrative of the gate's shape — canonical stages, in order. FAIL-FAST.
#   static        formatting + static analysis + lint      (no device)
#   jvm           unit tests  ← the new regression test runs here
#   screenshot    rendered-UI snapshot verification         (no device)
#   e2e           declarative UI smoke flows                (device)
#   instrumented  database / navigation / integration       (device)

The claim this illustrates: the cheap, deterministic checks run first and can veto a build before any slow device stage is spent. With the regression test sitting in the jvm stage, this behaviour can't get past the gate without turning it red — seconds of compute, long before a user's batch.

That's the whole difference. Not that the test makes the bug impossible everywhere — a different code path could always reach a similar failure — but that this regression can no longer reach a release through this path without announcing itself in red. The net doesn't have to be clever. It has to run every time, and it has to remember the failures nobody else will.

What actually separates the two kinds of app

It usually isn't that one team makes far fewer mistakes. Regressions come with change itself, so any codebase that keeps shipping keeps producing them. The difference is whether each one, once found, is converted into something that stays found — a red bar with the failure's name on it, instead of a memory that fades by the next sprint.

If your releases keep reviving bugs you're sure you already fixed, or your team ships a fix for A and hears about B a week later through a one-star review, that's not carelessness and it isn't a talent problem. It's a missing net — and unlike talent, a net is the kind of thing that can be built after the fact. It usually starts with a gate that can say no and the discipline of writing the failure down as a test the moment it's caught. If the symptom instead is an app that passes every check and still lets users down quietly, that's a related pattern worth reading next.


Ramiz Raja is a senior mobile developer and technical lead with 12+ years shipping and rescuing production apps on Android, iOS, and Flutter. More at codebyramiz.com.