The Thumb That Ran Ahead of the Finger: Anatomy of a Compose Slider Bug

A bug report sat in the backlog for seven months. The fix took a day, deleted a third of the slider’s logic, and was almost shipped with a second bug inside — until a machine read my diff better than I did. This is the whole story: the ported behavior that never made sense, the race nobody reported, and why the best fixes remove code.

The report

The bug report was seven months old when it reached me. A QA engineer had filed it in December, with a screen recording and three steps:

  1. Open the people search filters.
  2. Grab the right-hand thumb of the age range slider.
  3. Drag it slowly to the left.

The recording showed the problem in two seconds. The thumb does not follow the finger. It sprints ahead — you are still crossing the gap between two ticks, and the thumb is already standing on the next one, waiting for you, like a dog that runs to the park while you are still tying your shoes.

Expected: the thumb moves with the finger. Actual: it escapes. On a budget phone, on two Android versions, every time. Not a race condition, not a flake — a perfectly deterministic lie, filmed in December, assigned to a fix-day in July.

Seven months is not negligence. It is the natural half-life of a visual glitch filed as minor against a screen nobody dies on. The age filter works. You tap a tick, the value applies. Only the drag — the one gesture the slider exists for — feels wrong in a way that is hard to screenshot and easy to deprioritize.

When the ticket finally landed on my desk, a colleague glanced at it and asked whether it had already been fixed once. It had not. But I understood his confusion later, when I opened the slider’s source and found a comment that explained everything — including why this bug felt so old.

The comment that confessed

The slider is not the framework’s slider. Our design system ships its own Compose slider, forked from the AOSP Material one, because design needed three knobs the framework does not expose: a custom thumb radius, a custom track height, a different elevation on the thumb. The file lives in the design-system module, and every slider in the app that follows the design system — filters, settings, anywhere — descends from this one file.

At the top of it, above the composable, sat four lines of doc comment. Translated from the original Russian, they said, in effect:

I added passing through thumbRadius and trackHeight. I changed the thumb elevation. I added snapping to the tick value during the drag, without animation — the way it was in the View system.

Read the third line again. Someone — honestly, respectfully — ported a behavior from the old View-based slider into the Compose rewrite. They documented it. They were proud of the fidelity. And the reason the old View slider snapped mid-drag was never asked, because nobody ports questions, only answers.

That comment was the whole case. The bug was not an accident of implementation. It was a policy, imported wholesale, running exactly as designed. The View system’s slider snapped to ticks while your finger was still moving; the Compose fork inherited that; the QA recording was the receipt.

Two offsets, one lie

Here is the mechanism, stripped to the parts that matter. The AOSP slider pattern — which our fork extended — tracks the thumb position in a state called rawOffset: the raw pixel position of the finger along the track, updated on every drag delta.

Our fork added a second state:

Two states. rawOffset is where your finger is. snappedRawOffset is where the thumb is allowed to be. Identical at birth. And then, on every single drag delta:

snap() re-derives the nearest tick from the finger position and writes it into snappedRawOffset. The thumb is then drawn from snappedRawOffset – from the conclusion, not the input. Your finger says 43%; the nearest tick says 45%; the thumb renders 45% while you are still en route. Drag slowly, and the thumb lives one tick in the future, always.

Why does it run ahead specifically? Nearest-tick math. While you move toward a tick, the nearest tick is usually the one in front of you, not behind. The thumb teleports to where you are going and waits. Drag fast and you barely notice — the animation of your own motion masks it. Drag slowly, exactly as the QA steps said, and the gap becomes grotesque: the thumb parks on a tick while your finger is visibly mid-nowhere.

And there was a second symptom, filed by a second QA engineer on a re-test: sometimes the drag stops right after it starts, and you have to lift the finger, tap the thumb again, and hold. The AOSP drag machinery routes every gesture and every settle-animation through a mutual-exclusion lock — a new mutation can cancel the one in flight. My best reconstruction, and I am hedging because I never reproduced it on camera: an asynchronous snap animation still holding the mutex when you re-grabbed the thumb meant the user’s drag was the mutation that got cancelled. Lift, tap again, and now the animation has finished and the mutex is free. Two symptoms, one root cause family — a slider that kept two books.

Spot the pattern in your own codebase

Four greps, five minutes, and this entire class of bug hands itself in:

  • Two states feeding one drawn property (rawSomething and snappedSomething both alive at once) is not efficiency, it is a disagreement waiting for a scheduler.
  • Any snap, animate, or coerce call inside the drag delta handler deserves the question the original porter never asked: why here and not on release?
  • LaunchedEffect(Unit) watching a remembered state is a subscription to a specific object, not to a name. The moment anything re-keys that remember, the effect is a wire to nowhere. Grep for the pair; every codebase has at least one.
  • If your bug fix adds a state, stop. The fix for a two-state lie is almost never a third state.

The fix: one source of truth

The fix is almost embarrassingly small to describe. Delete the second book.

The thumb follows rawOffset – the finger – during the drag, always, smoothly. Snapping happens once, in gestureEndAction, when the gesture ends: take the raw position, compute the nearest tick, land on it. Not during the drag. Not per delta. Once, at the end, when the user has said their piece. In the range slider – the two-thumbed one, where the real danger lives – the settle is written synchronously, inside the gesture-end handler itself:

No launch, no next frame, no window for a second gesture to slip in between the decision and the write. The single-thumb slider, which has nothing to cross, keeps an animated settle — a lone thumb drifting onto its tick over one short tween is a luxury the range slider cannot afford. The distinction is the invariant: synchronous exactly where a race exists, animated where it cannot.

That is the entire behavioral change: snap moves from the drag loop to the release. Everything else was demolition. The snap() helper – gone. The SliderToTickAnimation class that existed to animate the mid-drag snapping – gone. Three duplicate snappedRawOffset* states across the single and range variants – gone. A coroutine scope that existed only to serve them – gone, from the range slider entirely.

The final diff: 46 lines added, 79 removed. I went in to fix a gesture bug and came out having deleted a third of the file’s logic. That is not a coincidence; it is the signature of the right fix. The two-state design was not load-bearing. It was the bug’s infrastructure.

The race nobody reported

While removing the snapping from the drag path, I found something the QAs never filed — because it requires inhuman fingers to reproduce reliably.

The old release-snap was asynchronous: it set the snapped value inside scope.launch, on the main dispatcher, one frame later. Usually fine. But fire a fast second drag immediately after release – before that coroutine lands – and the values are still sitting between ticks when the new gesture starts. From between ticks, “nearest tick” is ambiguous in the worst way: for a range slider, the nearest tick of one thumb can land past the other thumb. The two thumbs cross. They collapse into one.

Users experience this as “sometimes the handles jump through each other if I’m fast” — the kind of report that never gets filed because nobody is sure what they saw. The fix makes the release-snap synchronous: compute the tick, write it, notify, all before the gesture handler returns. The comment I left in the code says, in paraphrase: the snap must land before the next drag can start, or the two thumbs can cross; synchronicity is not an optimization here, it is the invariant.

Delete the mid-drag snap, and the race dies with it — not because I chased it, but because I removed the room it lived in.

The companion bug, and the machine that read my diff

The same ticket had a quieter companion. The filters sheet keeps a local copy of the age range so the slider is smooth while the sheet is open; a remembered state seeded from the search parameters once. The Clear button resets the search parameters – but not the local copy, because remember does not re-run for a living composable. Press Clear, and the sheet politely kept your previous age range. Classic.

My fix: a reset counter — bump an integer when Clear is pressed, and key the remember on it:

Re-keyed remember creates a new state object on reset. Correct. And my first version of the MR shipped with a hole exactly one line wide, three lines below:

LaunchedEffect(Unit) keeps listening to the state object it captured at first composition – the orphaned one. After a reset, the slider on screen writes into the new state; the effect still collects the old one, which never changes again. The user sees the thumb move. The filter silently does not apply. A worse bug than the one the ticket was about: invisible, and it corrupts the feature’s core promise.

The thing that caught it was not a human. Three humans owed approvals on that MR; all three read the diff; the hole was one line wide, and none of us saw it. Reviewers were pattern-matching slider math, not lifecycle captures. I could not see it because I had written it that morning.

An AI code reviewer attached to the merge request read the diff and posted, in its flat machine voice: after Clear, the effect listens to an orphaned state object; slider movements no longer reach the filter; key the effect on the same signal. It attached a sketch of the one-line fix:

The telemetry printed under the review says the model was deepseek-v4-flash, and that it spent seven minutes and 92,678 tokens on my diff — 57,776 reading, 31,805 reasoning, 3,097 writing. Over a million if you count cache reads. Three humans with full context missed the hole; the machine, reading every line at once because it has no skimming mode, did not.

I did not paste its snippet. I fixed it myself, with my own hands — the same one line, because once seen, the fix is obvious, but I wanted to walk the path: what LaunchedEffect(Unit) captures, when, and why re-keying remember does not re-key the effect that watches it. That walk was the reason I took the ticket on a fix-day at all. Compose state mechanics is the fun part; no AI touched the fix.

The machine also flagged the stale doc reference my demolition had left behind, and a human reviewer asked me to drop a generated-baseline hunk that did not belong in the commit — so the review stack ended up correctly balanced: the machine caught what humans skim past, the humans caught what the machine has no taste for. Three approvals later, the MR merged: 46 added, 79 removed, and the final follow-up delta was four lines.

I do not want to over-romanticize any of this. The machine did not “understand” my slider; it matched a structural pattern — re-keyed remember, un-keyed effect — that it has seen ten thousand times. That is still worth seven minutes and 92,678 tokens, because it saw it. But the audit before trusting it, and the fix after, were mine — and that division of labor is the honest one for 2026: machines read, humans decide.

One report, a thousand sliders

Here is the scope twist. The QA report was about the age filter — one screen, one sheet, two thumbs. But the bug never lived in that screen. It lived in the design-system component, several layers down, in the file every design-system slider in the app descends from.

Which means for seven months, every slider in the product that follows the design system was running ahead of every finger. The age filter just happened to be the place where someone dragged slowly enough, cared enough, and filed.

This is the quiet deal design systems make with you. Fix a component bug once, and the fix fans out to every screen that uses it — the same leverage that turned one sloppy port into a product-wide tic also turned one good deletion into a product-wide cure. The leverage is symmetric. It does not care which direction.

What the numbers say

The ledger for this ticket, for those keeping score at home:

  • Bug lifetime: about seven months from report to fix-day.
  • Reports: two QA filings, two different visible symptoms (thumb escapes; drag drops), zero reports of the thumb-crossing race.
  • Root cause: one ported behavior, faithfully documented, never questioned.
  • The fix: 46 lines added, 79 removed, three files. Three states deleted, one coroutine deleted, one animation class deleted, one helper function deleted.
  • Bugs introduced by the fix and caught in review: exactly one, one line wide, caught by an AI reviewer, fixed by keying one effect.
  • Human pedantry survived the transition: the machine also flagged my stale doc comment. Fair.

The meta-lesson compresses to three lines. Port intent, not implementation — the View system’s mid-drag snap was an answer to a question nobody asked on this platform. One source of truth beats two coordinated ones, every time, because coordination is where bugs breed. And the best fixes delete: if your bug fix adds net code, you have usually built a second room for the bug to move into.

The thumb now trails the finger like a thumb should, catches up on release, and lands on a tick. The dog walks to the park with you. It took a December recording, a July fix-day, a comment that confessed, and a machine that reads.

The slider in question descends from the AOSP Compose Material Slider, Apache 2.0 – the upstream pattern (rawOffset, tick fractions, gesture end snapping) is public and worth reading in full. If your own slider keeps two states, one for the finger and one for the display, this article is a mirror. Look into it.


The Thumb That Ran Ahead of the Finger: Anatomy of a Compose Slider Bug was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.