📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

A bug only appears in the Release build in production and you can't reproduce it in Debug. How do you investigate and fix it?

Debugging Release-only bugs: the usual causes (removed debug-only code, JIT optimisations exposing races and non-volatile reads, shortened object lifetimes, timing), a systematic investigation with Release builds, logs, dumps and symbols, and preventing recurrence.

Mid-Level Debugging Advanced build-configuration concurrency debug-vs-release debugging jit

🎯 Key points

  • Reproduce with the exact Release build first: dotnet run -c Release or the published artefact, not a Debug build
  • Check for code that differs: #if DEBUG, Debug.Assert with side effects, [Conditional] calls, DEBUG-only configuration
  • Optimisations can expose data races and non-volatile shared flags that a loop reads once and caches
  • Optimised code can end object lifetimes early, so timers and callbacks held only by locals stop working
  • Gather evidence from production: structured logs, a memory dump with matching PDBs, and dotnet-counters or traces
  • Fix the underlying bug (synchronisation, Volatile/Interlocked, keeping references alive), never by shipping Debug
  • Prevent recurrence: run CI tests and load tests in Release, keep Release PDBs, and use analysers for threading mistakes

⏱ 30-second answer

First I reproduce it with the same Release build, using dotnet run -c Release or the published artefact, because a Debug build is a different program. Then I check for code that differs between the two: #if DEBUG blocks, Debug.Assert with side effects, and debug-only settings. If nothing differs, I look for things optimisation exposes. A data race or a non-volatile flag that a loop reads once and caches. An object collected early because only a local variable held it, such as a timer. Or plain timing changes. For evidence I use production logs, a dump opened with the matching PDBs, and dotnet-counters. Then I fix the real bug with proper synchronisation, Volatile or Interlocked, or by holding references in fields. Finally I make CI test the Release build.

🗣 Full answer (senior)

How I would answer this in an interview

"My approach is to treat it as a real bug that Debug was hiding, so I reproduce it with the exact Release artefact, or at least dotnet run -c Release with the same publish settings and production-like load. I check for code that literally differs, like #if DEBUG blocks or a Debug.Assert with side effects, and otherwise look for what optimisation exposes: a data race or a non-volatile flag that a loop reads once and caches, or an object collected early because only a local held it. For evidence I use production logs, a dump with the matching PDBs and dotnet-counters, and the mistake I see is adding sleeps or logging until the bug disappears, which just hides the race.

For example, [our order-export worker at company] sometimes refused to stop during deployments, so Kubernetes killed it and the partner received half-written files, but it never happened in Debug. My task was to find the root cause rather than just raise the grace period. I reproduced it with the Release artefact, took a dump during the hang, and saw the stop flag was already true while the loop kept running, because the optimised JIT had hoisted the read of a plain bool. I replaced it with the host's CancellationToken, made file writes atomic, and added a Release-mode shutdown test to CI. As a result, we had no more killed pods or partial files in [N months] of deployments.

The fix is always the design, never shipping Debug: proper synchronisation, Volatile or Interlocked, or references held in fields. Then I close the gap with CI tests and load tests in Release, archived Release PDBs and threading analysers, because being able to explain why the optimiser was allowed to do what it did matters more than 'we added a lock and it went away'."


Step 1: Reproduce with the shipped build

  • Build exactly what production runs: dotnet publish -c Release with the same TFM, RID, runtime version and publish settings (self-contained, ReadyToRun, trimming), or better, download the exact pipeline artefact that was deployed.
  • Run it under production-like conditions: the same OS and container image, the same GC mode and CPU/memory limits, realistic load and concurrency, and real configuration shape (without production secrets).
  • If it only fails under load, use a load-testing tool against the Release build in a test environment.

Step 2: Look for code that is literally different

  • #if DEBUG / #if !DEBUG blocks.
  • Debug.Assert(SomethingWithSideEffects()), Debug.WriteLine(...) and other [Conditional("DEBUG")] calls: removed in Release, with their arguments.
  • Configuration or DI registrations that depend on IHostEnvironment.IsDevelopment(); strictly that's environment, not configuration, but it's often confused with Debug.
  • Search: git grep -n "#if DEBUG\|Debug\.\|IsDevelopment".

Step 3: Look for what optimisation exposes

Symptom Likely cause
A background loop never sees a stop flag change Non-volatile field read hoisted out of the loop by the JIT
Counters or collections corrupted under load Data race (non-atomic ++, List<T> or Dictionary<,> shared across threads)
A timer or callback stops after a while Object only referenced by a local; collected after its last use
Rare NullReferenceException or stale reads Missing synchronisation, double-checked locking done wrong
Different floating-point results in the last digits Different instruction selection (rare on modern .NET, but possible)

These bugs are non-deterministic: they depend on timing, core count and what the JIT decided, so they can appear on one machine and not another, or vanish when you add logging.

Step 4: Get evidence from production

  • Structured logs with correlation IDs around the failure.
  • A memory dump (dotnet-dump collect, or automatic dumps on crash), analysed with the matching Release PDBs: look at thread stacks and object state. Treat the dump as production data: it contains whatever was in memory, including personal data and secrets, so collect it with approval, store it encrypted with access controls, and delete it when the investigation ends.
  • dotnet-counters for thread pool, GC and exception rates; dotnet-trace for timing.

Step 5: Fix the real bug

  • Shared flags: volatile, Volatile.Read/Volatile.Write, or better, a CancellationToken.
  • Shared counters and state: Interlocked, lock, or concurrent collections.
  • Lifetimes: store timers, subscriptions and handles in fields and dispose them properly; GC.KeepAlive only as a last resort.
  • Remove side effects from debug-only calls.
  • Prove the fix in Release: a regression test that runs in CI with -c Release.

Production example

An order-export worker on .NET 10 sometimes never stopped during deployments in Release, so pods were killed after the grace period and exports were half-written. In Debug it always stopped. The loop read a plain bool _stopping field that another thread set; the JIT hoisted the read out of the loop in optimised code. Replacing the flag with the host's CancellationToken (stoppingToken.IsCancellationRequested) fixed it, and a Release-mode integration test now checks shutdown completes within five seconds.

Best practices, performance and security

  • Never "fix" a Release-only bug by deploying Debug: it's slower, larger and hides the defect.
  • Keep symbols for every Release build so dumps are readable.
  • Run tests, especially concurrency and shutdown tests, in Release in CI.
  • Dumps can contain personal data; store and share them under the same controls as production data (Privacy Act obligations apply).

🔍 What the interviewer is checking

Probes debugging method and understanding of what changes between configurations.

Green flags: reproduces with the Release artefact; checks debug-only code; names optimisation-related causes (hoisting, races, early GC); uses dumps with symbols; fixes root cause and adds Release testing.

Red flags: ships Debug; blames the compiler; adds sleeps; no idea why optimisation changes behaviour.

📚 Learn in depth

1. A Release-only bug is a real bug

Debug and Release run the same source, but not the same program. If behaviour differs, one of these is true:

Category What differs Typical symptom
Code that only exists in one build #if DEBUG, [Conditional("DEBUG")] calls and their arguments Work silently not done in Release; validation missing
Optimisations exposing undefined behaviour Hoisted reads, reordered writes, inlining Loops that never stop, stale values, rare NullReferenceException
Object lifetimes The JIT ends a local's lifetime after its last use Timers, callbacks or event subscriptions that stop working
Timing Faster code, different thread interleaving Races, deadlocks and ordering bugs under load
Publish settings that only apply to production Trimming, ReadyToRun, Native AOT, self-contained, a different runtime patch Missing types or members via reflection, different start-up paths
Environment, not configuration Production vs Development, container limits, server GC, CPU count Behaviour tied to settings rather than the build

The last two aren't strictly Debug versus Release, but they're often what "only in Release" really means. Rule them in or out early.

2. A systematic investigation

 1. Get the exact artefact that's deployed (same pipeline build, same PDBs)
        |
 2. Reproduce with it, under production-like conditions
    (same image, CPU/memory limits, GC mode, load, config shape)
        |
        +-- can't reproduce --> collect evidence from production (section 4)
        |
 3. Narrow down: change ONE dimension at a time
    Release vs Debug | trimming on/off | R2R on/off | tiered PGO on/off | env | load
        |
 4. Read the code that differs (#if, Conditional, IsDevelopment)
    and look for shared mutable state, locals that must stay alive
        |
 5. Form a hypothesis and prove it with a minimal Release reproduction
        |
 6. Fix the cause, add a Release-mode regression test, close the gap in CI

Narrowing knobs

These runtime settings change how code is compiled without rebuilding, which helps confirm that the JIT is involved. Use them only to investigate, never as the fix.

Setting (environment variable or runtimeconfig.json) Effect
DOTNET_TieredCompilation=0 Methods are compiled fully optimised straight away (no tier 0)
DOTNET_TieredPGO=0 Turns off dynamic profile-guided optimisation
DOTNET_ReadyToRun=0 Ignores precompiled code and JITs everything
DOTNET_gcServer=0 / 1 Switches between workstation and server GC

If the bug appears or disappears with one of these, you've learned that optimisation or GC timing is involved, which points at a race, a missing barrier or a lifetime problem in your code.

At the code level, [MethodImpl(MethodImplOptions.NoInlining)] on a suspect method is a quick way to test an inlining hypothesis.

3. The memory model in practice

To run fast, the compiler, the JIT and the CPU may keep values in registers and reorder reads and writes, as long as a single thread can't tell the difference. Other threads can tell. Synchronisation primitives include the memory barriers that stop this:

Primitive Guarantees Use for
lock Mutual exclusion plus full barriers on enter and exit Shared state with several fields or invariants
Interlocked.* Atomic read-modify-write with a full barrier Counters, flags, compare-and-swap
Volatile.Read / Volatile.Write, volatile fields The read happens every time and isn't moved earlier; the write isn't moved later Simple flags read in loops
CancellationToken Thread-safe signalling with the right barriers, plus callbacks Stopping work; the default choice for shutdown
Task, await, SemaphoreSlim, concurrent collections Their own synchronisation Most application code

Why the stop flag bug happens

Source:                               Optimised JIT output (conceptually):

private bool _stopping;              reg = this._stopping      <- read ONCE
                                     loop:
while (!_stopping)                     if (reg) goto done        <- never re-read
{                                      DoWork()  (inlined, no barrier)
    DoWork();                          goto loop
}                                    done:

Thread B: _stopping = true;  -> writes memory, but thread A only checks its register

Whether the JIT hoists the read depends on what the loop body contains and what gets inlined. Add a log line, and the call may act as a barrier and the bug disappears, which is why "adding logging fixed it" is a red flag, not a fix.

Early collection

In optimised code the JIT tells the GC only about locals that are still needed. After its last use, an object referenced only by a local can be collected before the method returns. Debug code extends lifetimes to the end of the method so the debugger can show the variable.

// BUG in Release: nothing references the timer after this line
var timer = new Timer(_ => Poll(), null, 0, 1000);

// FIX: keep it in a field and dispose it on shutdown
_timer = new Timer(_ => Poll(), null, 0, 1000);

4. Evidence from production

Evidence How Look for
Logs Structured logs with correlation ids around the failure The last thing each component did; whether "stopping" was followed by "stopped"
Memory dump dotnet-dump collect -p <pid> during a hang, or automatic dumps on crash (DOTNET_DbgEnableMiniDump=1) Thread stacks and object state
Counters dotnet-counters monitor -p <pid> Thread pool queue length, GC rate, exception rate, lock contention
Traces dotnet-trace collect Timing, contention events, GC pauses

Reading the dump

dotnet-dump analyze core_20261022
> clrstack -all                      # what every managed thread is doing
> syncblk                            # which threads hold or wait for locks
> threadpool                         # queued work and worker threads
> dumpheap -type OrderExportWorker   # find the instance
> dumpobj <address>                  # read its fields, e.g. _stopping = 1
> gcroot <address>                   # what keeps an object alive (or why nothing does)

Open the dump with the PDBs from the same build, or the stacks won't have line numbers. Treat dumps as production data: they contain whatever was in memory, including personal information and secrets, so collect them with approval, store them encrypted with restricted access, and delete them when the investigation ends.

5. Common causes and their fixes

Cause Fix
Plain bool stop flag CancellationToken (or Volatile.Read/volatile if a token doesn't fit)
count++ from several threads Interlocked.Increment
List<T> or Dictionary<,> shared across threads A lock, or ConcurrentDictionary / channels
Hand-written double-checked locking Lazy<T> or LazyInitializer
Timer or subscription held only by a local Store it in a field and dispose it
Side effects inside Debug.Assert Do the work first, assert on the result
Reflection broken by trimming Annotate with [DynamicallyAccessedMembers], fix trim warnings, or use source generators

6. Proving the fix

  • Write a test that reproduces the bug in Release (dotnet test -c Release), ideally failing reliably before the fix. For races, run the scenario many times or under stress.
  • Tools for systematic concurrency testing, such as Microsoft Coyote, can explore thread interleavings that ordinary tests rarely hit.
  • For shutdown bugs, an integration test that starts the published app, sends SIGTERM and asserts a clean stop within a time limit catches regressions.

7. Preventing the next one

  • Build, test and load-test the Release artefact you deploy; never test Debug and ship Release.
  • Keep PDBs for every Release build so production dumps are readable.
  • Turn on threading analysers (for example Microsoft.VisualStudio.Threading.Analyzers) and trim analysers (IsTrimmable, EnableTrimAnalyzer) where relevant.
  • Make shared mutable state a review checklist item, and prefer immutable data and message passing (channels) over shared fields.

8. .NET 11 awareness

Each release makes the JIT smarter (more inlining, better PGO, escape analysis and stack allocation). Code that relies on undefined behaviour can break on an upgrade even without any Release-versus-Debug change. Run your Release and concurrency tests when you trial .NET 11, and keep production on .NET 10 until they pass.

9. Try it yourself

  1. Run the code example with dotnet run (Debug): it stops after one second.
  2. Run dotnet run -c Release: it may never stop.
  3. Set DOTNET_TieredCompilation=0 and run the Debug build: does that change anything? (It shouldn't; Debug assemblies disable JIT optimisation.)
  4. Change the field to private volatile bool _stopping; and rerun in Release.
  5. Replace the flag with a CancellationTokenSource and rerun.
  6. While the broken Release build is spinning, run dotnet-dump collect -p <pid>, then clrstack -all and dumpobj on the worker to see _stopping already set.
  7. Write a test that runs the worker in a Release build and fails if it doesn't stop within five seconds.

💻 Code

namespace ReleaseOnlyBug;

internal sealed class Worker
{
    private bool _stopping; // BUG: plain field shared across threads
    private long _iterations;

    public void Run()
    {
        while (!_stopping)
        {
            _iterations++;
        }
        Console.WriteLine($"Stopped after {_iterations:N0} iterations");
    }

    public void Stop() => _stopping = true;
}

internal static class Program
{
    private static void Main()
    {
        var worker = new Worker();
        var thread = new Thread(worker.Run) { IsBackground = true };
        thread.Start();

        Thread.Sleep(1000);
        worker.Stop();

        Console.WriteLine(thread.Join(TimeSpan.FromSeconds(5))
            ? "Worker stopped"
            : "Worker did NOT stop: the read was hoisted in optimised code");
    }
}

The fix in a hosted service:

namespace Orders.Export;

public sealed class OrderExportWorker(ILogger<OrderExportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ExportBatchAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
        logger.LogInformation("Order export stopped cleanly");
    }

    private static Task ExportBatchAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Task.Delay throws OperationCanceledException on shutdown, which BackgroundService treats as a normal stop.

⚠ Common mistakes

  • Mistake: trying to reproduce with a Debug build. Debug disables the optimisations and extends the lifetimes that cause the bug, so you conclude "can't reproduce" and close the ticket. Instead: reproduce with the exact artefact that was deployed, or at least dotnet run -c Release with the same publish settings, under production-like conditions.

  • Mistake: deploying a Debug build to production as a workaround. It's slower and larger, changes timing, and only hides the defect, which returns as soon as someone redeploys Release. Instead: keep Release in production and fix the underlying race, lifetime or debug-only code.

  • Mistake: adding Thread.Sleep or logging until the bug disappears. Both change timing and often introduce memory barriers, so the race still exists but stops showing up, and comes back under different load. Instead: use the right synchronisation primitive (CancellationToken, Interlocked, lock, Volatile) and prove the fix with a Release-mode test.

  • Mistake: debugging the Release build with JIT optimisation suppressed. The IDE's "suppress JIT optimisation" option makes the code behave like Debug, so the bug vanishes while you watch. Instead: turn that option off when chasing a Release-only bug, and rely on dumps, logs and minimal reproductions.

  • Mistake: assuming the compiler or JIT is wrong. It almost never is. The code relies on behaviour the .NET memory model doesn't guarantee, such as another thread seeing a plain field change. Instead: look for shared mutable state without synchronisation and objects that must stay alive, and confirm with a minimal reproduction.

  • Mistake: making every shared field volatile. volatile only orders single reads and writes; it doesn't make count++ atomic or protect invariants across several fields. Instead: use CancellationToken for stopping, Interlocked for counters, lock for compound state, and concurrent collections or channels for queues.

  • Mistake: forgetting publish settings and environment. Trimming, ReadyToRun, AOT, server GC and container limits only apply in production, so the bug may have nothing to do with the build configuration. Instead: change one dimension at a time (configuration, trimming, R2R, PGO, GC mode, environment) to find which one matters.

  • Mistake: not keeping Release PDBs. Production dumps and stack traces have no line numbers, so the investigation becomes guesswork. Instead: archive PDBs for every Release build, or publish them to a symbol server, and keep them with the artefact.

  • Mistake: handling dumps like ordinary log files. A dump holds everything in the process's memory, including personal information, tokens and connection strings. Instead: collect dumps with approval, store them encrypted with restricted access, never attach them to public issues, and delete them when the investigation ends.

  • Mistake: putting side effects inside Debug.Assert. The whole call, including its arguments, is removed from Release, so work that ran in Debug silently stops. Instead: do the work in its own statement and assert on the stored result.

  • Mistake: fixing the bug without closing the gap that let it through. CI still tests Debug, so the next race or lifetime bug reaches production the same way. Instead: run CI tests in Release, add a Release regression test for the bug, and add threading analysers and a review checklist item for shared state.

🏛 Senior insight: trade-offs

A Release-only bug is a real bug that Debug was hiding, usually concurrency or lifetime related. A senior engineer investigates with the shipped artefact and production evidence (dumps with symbols, counters, logs), explains the mechanism (hoisted reads, early collection, removed debug code) and fixes the design rather than the symptom. Then they close the gap that let it through: Release builds in CI, shutdown and concurrency tests, and threading analysers. Being able to explain why the optimiser was allowed to do what it did is what separates a senior answer from "we added a lock and it went away".

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says 'Release is optimised' without detail. Why: Checks the candidate can name what actually differs between the configurations.
    How to answer

    A configuration is a named set of build settings. Debug turns optimisation off and defines the DEBUG and TRACE symbols, so the debugger can show every variable and step line by line. Release turns optimisation on and defines RELEASE and TRACE instead of DEBUG. Both produce PDBs by default. Behaviour can differ for three reasons. Code under #if DEBUG, and calls such as Debug.Assert, are removed from Release. The optimising compiler and JIT inline and reorder code and end variable lifetimes earlier, which changes timing and when objects are garbage collected. And race conditions hidden by slow Debug code start to show. dotnet build defaults to Debug, while dotnet publish and dotnet pack default to Release, so you should test the build you ship.

  2. When: Candidate searches for #if DEBUG or Debug.Assert. Why: Explores debug-only code paths as a cause.
    How to answer

    Conditional compilation decides at compile time which code exists. #if/#else/#endif test symbols. Those come from DefineConstants in the project, #define in a file, or implicit SDK symbols: DEBUG, framework ones like NET10_0_OR_GREATER, and OS ones like WINDOWS for net10.0-windows. The [Conditional("DEBUG")] attribute on a void method is different. The method is always compiled, but calls to it are removed wherever the symbol isn't defined in the calling code, including the evaluation of their arguments. I use these for framework and platform differences, such as multi-targeting. Anything that varies by environment or customer belongs in run-time configuration or feature flags, so one build can be deployed everywhere.

  3. When: Candidate talks about preventing threading mistakes. Why: Checks whether analysers could have caught the bug earlier.
    How to answer

    Analysers are plug-ins for the Roslyn compiler. They report diagnostics in the editor and during the build. CA rules (CA1xxx to CA5xxx) are the SDK's code quality analysers, covering design, performance, reliability and security. The .NET analysers are enabled by default for net5.0+ projects (EnableNETAnalyzers), but only a default subset of rules reports as warnings; AnalysisLevel and AnalysisMode set how many apply. IDE rules (IDE0xxx) are code style rules, such as var usage or file-scoped namespaces. They show in the editor but only run in the build when EnforceCodeStyleInBuild is true, which is why style issues often pass CI. Severities for both are set in .editorconfig (or a .globalconfig) with dotnet_diagnostic.<id>.severity. Third-party analysers come in as NuGet packages. For a team, I put the levels in Directory.Build.props, the severities in one root config file, and tighten them step by step.

🛠 Real-world problems: how you handle them

Export worker never stops during deployments in Release
Situation
An order-export background service on .NET 10 in AKS (Azure Australia East) exported batches to a partner SFTP server. During rolling deployments, some pods never stopped and were killed after the 30-second grace period.
Signals
Pod termination events with exit code 137; partially written export files on the partner server; logs showing 'stopping' but never 'stopped'; impossible to reproduce on a developer laptop running Debug.
Investigation
Ran the published Release artefact locally and reproduced the hang in about one run in three. Took a dump with dotnet-dump during the hang: the worker thread was spinning in the export loop, and dumpobj showed _stopping was already true. Code review found a plain bool flag set from StopAsync and read in a tight loop, which the optimised JIT had hoisted.
Resolution
Replaced the flag with the BackgroundService stoppingToken, passed the token to all I/O calls, and wrote export files to a temporary name before renaming on completion so partial files were never visible.
Prevention
Integration test that starts the Release build, sends SIGTERM and asserts clean shutdown within five seconds; CI tests run in Release; review checklist item for shared mutable state; dumps enabled on crash with symbols archived per build.

How to tell it (STAR)

**Situation:** Our [order-export worker] at [company] sometimes refused to stop during deployments, so Kubernetes killed it and the partner received half-written files. It never happened in Debug. **Task:** I took on finding the root cause rather than just raising the grace period. **Action:** I reproduced it with the Release artefact, took a dump during the hang, and saw the stop flag was already true while the loop kept running. The optimised JIT had hoisted the read of a plain bool. I replaced it with the host's cancellation token, made file writes atomic, and added a Release-mode shutdown test to CI. **Result:** No more killed pods or partial files in [N months] of deployments, and the shutdown test caught [a similar issue] in another service.

↩ Usually asked after

More in Build System

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me