# Why a bug only happens sometimes, and how to reproduce it

> A bug that fires on maybe one tap in eight is usually a race whose timing you can't control locally. Here is how to force it and record the run that broke.

*Answering the search: "why does a bug only happen sometimes and how do i reproduce it"*

## Evidence

Two threads on a shared timeline; on most runs the order is safe, and on roughly one run in eight they collide.

A bug that surfaces on one tap in eight and hides on the other seven is a race: two pieces of code touching the same state in an order you do not control, where the failing order only sometimes wins. You reproduce it by running the flow on one cloud device over and over until a run breaks, then keeping the full record of the run that did.

## One lap in eight, and you can't catch which one
You tap the lap button, the split logs correctly, and everything looks fine. You repeat it a dozen times to be sure, ship it, and a tester reports a phantom lap that showed up after they had already stopped the timer. Back on your machine you cannot make it happen again. You add a print statement, tap forty times, every tap is clean, and that somehow makes it worse.

## Intermittent means a race you don't schedule
raceday plants the race between the LAP and STOP buttons. `recordLap()` does not append to the laps array directly. It punts the write onto a background "persistence" queue, standing in for a Codable encode and an async disk write, which then hops back to the main queue to append the lap. `stop()` runs on the main queue too, with no barrier against that in-flight dispatch. Tap LAP and STOP within about fifty milliseconds and the deferred append can land after `stop()` has already flipped the timer off and stamped its final time. The visible result is an extra lap row that appears after the display has stopped, sometimes with a split later than the stop time. Most taps the append wins the race and nothing looks wrong, which is why testing by hand never catches it.

The same family shows up in a few other shapes. Swift Concurrency moves it onto suspension points: a method reads a value, hits an `await`, and writes back afterward, but the actor is free to run another Task during the suspension, so the value you read can be stale by the time you resume and two Tasks racing to bump the same count clobber each other. Mutating an `ObservableObject`'s `@Published` value off the main thread is another shape: SwiftUI observes it through Combine, which requires main-thread updates, so an off-thread write logs `Publishing changes from background threads is not allowed` and can corrupt the row or crash outright. And a true unsynchronized data race, two threads touching the same memory with no lock, is the classic version Thread Sanitizer was built to catch: it flags the missing synchronization whenever both accesses run unordered, so it does not need the harmful interleaving to happen, only a build compiled with the sanitizer on.

React Native shows the same shape when a JS callback and a native module write the same store, and Flutter when a Future completes after its widget was already disposed and calls `setState` on nothing.

## What the bad run leaves in the record
Every one of these depends on an interleaving you cannot predict, so a record of one passing run tells you nothing. You need the record of the run that broke, and everything the report captures is an outside observation, never a peek at the app's memory.

In the video, the stray lap row appearing after the timer has stopped is right there on screen. The timeline, the step-by-step agent trajectory, carries a grounding screenshot and timing for every step, so it marks the LAP tap and the STOP tap that produced it and shows the lap list before and after. The device logs from that same run carry any runtime error or crash stack, pinned to that moment; raceday also logs each lap commit and the stop, so a commit landing after the stop shows up there in the order it actually happened. If a memory data race were the culprit instead and you ran that build on the cloud device with Thread Sanitizer compiled in, its report would land in those same logs, because the sanitizer writes to the run's log stream. A plain dev or release build hands you the runtime error and the screens, not a sanitizer report.

For the off-main mutation variant, the `Publishing changes from background threads is not allowed` warning is right there in the device logs, and the Performance chart, which tracks frame rate, CPU, and memory across the run, shows any stall where a render collided with the write, lined up against the hitch in the video.

Some of this you can chase locally. A true data race trips Thread Sanitizer on the simulator the moment you exercise the unsynchronized path in a sanitizer build, because it flags the missing lock, not the collision. What you cannot do on your own machine is the rest. The failing order rides on timing you do not control, the gap between two taps against a deferred main-queue hop, and you may not have the OS build it was reported on installed. A simulator also runs on your host's cores rather than a phone's performance and efficiency cores, so a race that only surfaces under device scheduling is one you pin with a sanitizer build, not by waiting for the interleaving. And you cannot tap two hundred times by hand under full capture. The way to force a timing race like raceday's is to boot the reported OS build and run the flow hundreds of times under full capture, so the rare failure lands in a record you can read.

## Force it on the device where it lives
This is where a cloud device lab does the work your laptop cannot. Spin up the exact model and OS where the bug was reported, install the build, and run the raceday lap flow as an agent test that repeats the LAP-then-STOP sequence many times over, so the rare failure becomes a matter of when rather than if. raceday, a SwiftUI lap timer with a planted race that misfires on roughly one lap-then-stop in eight, is the worked example: point the flow at it, let it hammer the lap and stop buttons until a run fails, then open the report for that run. You get the video with the stray lap on screen, the timeline marking the bad tap, and the device logs with the runtime error or the out-of-order lap commit, all from the run that actually broke. A live session lets you tap and watch it happen by hand.

```bash
revyl auth login
revyl init
revyl test run raceday --build
revyl device start --platform ios
```

## Technical sources

- [Apple: Diagnosing memory, thread, and crash issues early](https://developer.apple.com/documentation/xcode/diagnosing-memory-thread-and-crash-issues-early)
- [Apple: Thread Sanitizer](https://developer.apple.com/documentation/xcode/data-races)

## Common questions

**Why can't I reproduce a bug that only happens sometimes on my machine?**

The failing order rides on timing you do not control, like the gap between two taps landing against a deferred main-queue write, and pinning it often needs the exact OS build the report came from. On your machine the safe order wins nearly every time, so you never see it. You reproduce it by booting that OS version, running the flow hundreds of times, and keeping a full trace of the one run that breaks.

**Is an intermittent bug always a race condition?**

Not always, but on native iOS it usually is: work deferred onto a background queue that lands back on main out of order, a stale read across an await suspension point, or an off-main mutation of an `@Published` value. All of them depend on an order of operations you do not control, which is why they fire only occasionally and pass every time you test by hand.

**How do I reproduce an intermittent iOS crash to actually debug it?**

Run the flow that triggers it on the model and OS build it was reported against, and repeat the interaction hundreds of times so the rare failure stops being a coin toss. Capture the run that breaks with a video, the timeline marking the tap that failed, and the device logs carrying the runtime error or crash stack (or a Thread Sanitizer report, if a memory data race was the cause and that build was compiled with the sanitizer on). The report shows you the corrupted screen from the outside; you infer the internal cause from what it captured.

---

Reproduce it on a cloud device with Revyl: https://revyl.com/use-cases/bug-only-happens-sometimes/ · https://app.revyl.ai/signup
