Heisenbug: What It Is, Why It Occurs, and How to Catch It

Author: IT Sectr Published: 2026-07-29 Reading time: 10 min

Heisenbug — a bug that disappears when you try to debug it. The term comes from Heisenberg’s uncertainty principle: observation affects the behavior of the system. In mobile development, Heisenbug is one of the most difficult problems because standard debugging methods (logs, breakpoints, additional code) change the program state and hide the bug. Let’s explore the causes and methods for dealing with elusive errors.

Key Takeaways

  • Race condition — the main cause of Heisenbug: changing timing during debugging masks the problem
  • Bohrbug — a predictable bug, easily reproducible unlike Heisenbug
  • Mandelbug — a bug with complex cause-and-effect relationships, sensitive to initial conditions
  • ThreadSanitizer — a tool for detecting data races that does not affect timing
  • Deterministic tests — the only reliable way to reproduce a Heisenbug

What Is a Heisenbug in Mobile Development?

Heisenbug — a class of errors that manifest in production or during normal operation but disappear when you try to reproduce them in a debugging environment. The term was coined in the 1980s by programmer Jim Gray in the context of distributed systems, but it is most relevant today for mobile applications due to their asynchronous nature.

The main reason: standard debugging tools change the execution environment. A breakpoint pauses the thread for several milliseconds, logging adds synchronous I/O, additional checks change the order of operations. In a multithreaded environment, even a microsecond delay can alter the thread execution order and hide a data race.

According to Microsoft Research (2022), about 15-25% of all bugs in multithreaded mobile applications are classified as Heisenbug. At the same time, the time to find and fix one Heisenbug is on average 5-10 times longer than for a regular bug, due to the inability to reproduce it directly.

Heisenbug Example

An app crashes in production when quickly swiping through a list, but when connected to a debugger or adding logs — it works perfectly. Cause: a data race between the UI thread (updating RecyclerView) and the background thread (updating adapter data). Logs add a delay that randomly synchronizes the threads.

Bohrbug, Mandelbug, Heisenbug: Bug Classification

Bohrbug — a predictable, stably reproducible bug. Named by analogy with Bohr’s atomic model: like an atom, the bug behaves the same way every time it’s observed. Example: NullPointerException when clicking a button before data loads. Treated with standard unit testing.

Mandelbug — a bug with a complex, chaotic cause-and-effect relationship (named by analogy with the Mandelbrot set). Manifests only under a certain combination of conditions: OS version, device model, network state, phase of the moon. It differs from Heisenbug in that it does not disappear during debugging — the problem is difficulty of reproduction, not behavior change from tools.

Heisenbug — a bug that disappears precisely because of debugging tools. If you add a log — the bug disappears. If you set a breakpoint — the bug does not manifest. If you remove everything — the bug returns. The main cause: altered timing during debugging.

TypeReproducibilityReaction to DebuggingExample
Bohrbug100%UnchangedNPE on empty list
MandelbugChaoticUnchangedCrash on Android 12, Samsung, low battery
HeisenbugOnly without debuggingDisappearsRace condition disappearing with logs
SchrödinbugDoes not manifest in codeAppears when looked atBug visible in code but never triggers

Main Causes of Heisenbug

Race condition — the number one cause of Heisenbug. Two threads access shared data without synchronization. The debugger introduces a delay, causing the threads to synchronize naturally. Without the debugger, the execution order is unpredictable.

Timing-dependent errors — bugs that manifest only at a certain execution speed. For example, an animation that must finish before the next operation starts. In the debugger, the animation runs slower, and the operation has time to start after the animation finishes. In production — the opposite.

kotlin
// Example race condition — typical Heisenbug
class ListViewModel : ViewModel() {
    private var items = mutableListOf<String>()

    fun loadFromNetwork() {
        viewModelScope.launch(Dispatchers.IO) {
            val result = api.fetchItems()
            items.addAll(result) // ❌ Not thread-safe
        }
    }

    fun getItems(): List<String> = items.toList()
    // Race condition: getItems read may overlap with loadFromNetwork write
}

Compiler optimization — the compiler (JIT, ART, Kotlin/Native) may reorder instructions for optimization. In a debug build, optimizations are disabled and the code executes “as written.” In a release build, the compiler changes the order of operations, which can reveal hidden assumptions in the code.

  • ThreadLocal — incorrect use of thread-local variables that are not visible to other threads
  • Uninitialized variables — code relying on default values of class fields
  • GCD/dispatch queues — in iOS, undefined order of block execution in concurrent queues
  • Buffered I/O — data is not written to disk until the buffer is full

Strategies for Catching Elusive Bugs

ThreadSanitizer (TSan) — a Google tool for detecting data races in C/C++ and Kotlin/Native. It is embedded into the build and detects any shared memory access without synchronization. Unlike logs, TSan does not affect timing because it works through instrumented code rather than I/O.

Deterministic tests — replace real asynchrony with controlled asynchrony. Use TestDispatcher (Kotlin), RxJava Plugins, or GCD test queues (iOS) for full control over execution order. Specify concrete scenarios: thread A runs, then B, then A again.

Cyclic logging — logging to a ring buffer in memory (not disk). When the bug occurs, the buffer is saved to a file. Since writing to memory takes nanoseconds (instead of milliseconds for disk I/O), such logging does not affect timing and does not mask the Heisenbug.

kotlin
class CyclicBuffer(val capacity: Int = 1000) {
    private val buffer = ArrayDeque<String>(capacity)
    private val lock = Any()

    fun log(message: String) {
        synchronized(lock) {
            if (buffer.size >= capacity) buffer.removeFirst()
            buffer.addLast(message)
        }
    }

    fun flush() {
        synchronized(lock) { buffer.forEach { fileWriter.write(it) } }
    }
}

Production logging — if the bug is not reproducible locally, collect data in production. Use Firebase Crashlytics logs, Sentry Breadcrumbs, or a custom cyclic logger. Important: logging should be asynchronous and have minimal performance impact.

Heisenbug Prevention at the Architecture Level

State isolation — minimize shared mutable state. Each component should have its own isolated state, inaccessible for direct write from other components. Use Unidirectional Data Flow (UDF) — state flows in one direction: Event → Reducer → State → UI.

Functional approach — pure functions without side effects are easier to test and debug. Isolate side effects (network, DB, files) in strictly defined layers (repository, data source). Threading errors in functional code are virtually impossible.

Strict mode — enable Android StrictMode in debug builds. It detects threading policy violations (network on main thread, disk I/O on main thread) and throws an exception. This turns a potential Heisenbug into a deterministic Bohrbug that is immediately visible.

kotlin
class DebugApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

Code review with focus on asynchrony — a mandatory part of the process. Every pull request should be checked for shared mutable state, non-thread-safe collections, and missing synchronization. Use lint rules to automatically prohibit certain patterns (e.g., accessing MutableList without synchronized).

Frequently Asked Questions

Why is Heisenbug so hard to find?

Because standard methods — breakpoints, logs, print — change the execution environment so much that the bug stops manifesting. The debugger pauses all threads for tens of milliseconds. During this time, the race condition that caused the bug naturally resolves. Tools that do not affect execution timing are needed.

How is Heisenbug different from Mandelbug?

Mandelbug is hard to reproduce due to the complexity of conditions, but debugging tools do not affect its manifestation. Heisenbug, on the other hand, disappears precisely because of debugging tools. Example of Mandelbug: a crash only on devices with Android 11, 3 GB RAM, and battery level below 15%. Example of Heisenbug: a race condition that disappears when adding Log.d().

How to test Heisenbug in CI/CD?

Use flaky test detection — tests that sometimes fail, sometimes pass. In Android, use Android Test Orchestrator for test isolation. Add StrictMode to debug tests. Instrument the build with ThreadSanitizer. If a test is flaky >5% of runs — consider it a potential Heisenbug and investigate before merging.

Does Flow/Coroutines help avoid Heisenbug?

Partially. Flow and structured concurrency in Kotlin reduce the amount of shared mutable state and simplify thread management. But coroutines do not guarantee thread safety: if two coroutines share state, a race condition is still possible. Use Mutex to protect shared state or Channel to pass data between coroutines.

What to do if Heisenbug only manifests in production?

Use a cyclic log buffer in memory with automatic flush on error. Add detailed monitoring via Crashlytics or Sentry with custom breadcrumbs. For Android, enable ANR detection and check traces. If the bug is a race condition, ThreadSanitizer in a debug build with production-like load may reveal the problem.

Summary

  • Heisenbug — a bug that disappears when you try to debug it; the main cause is timing changes from developer tools
  • Race condition — the main cause of Heisenbug in mobile applications, especially in asynchronous code
  • Bohrbug (100% reproducible) and Mandelbug (chaotic) — other bug types, not to be confused with Heisenbug
  • ThreadSanitizer — the best tool for detecting data races, without affecting execution timing
  • Cyclic logging in memory instead of disk — a way to collect data without masking Heisenbug
  • Unidirectional Data Flow and minimizing shared mutable state — architectural prevention of a whole class of errors
  • StrictMode in debug builds turns a potential Heisenbug into a deterministic Bohrbug, immediately visible

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also