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.
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.
"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 Releasewith the same publish settings and production-like load. I check for code that literally differs, like#if DEBUGblocks or aDebug.Assertwith 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 anddotnet-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,
VolatileorInterlocked, 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'."
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.#if DEBUG / #if !DEBUG blocks.Debug.Assert(SomethingWithSideEffects()), Debug.WriteLine(...) and other [Conditional("DEBUG")] calls: removed in Release, with their arguments.IHostEnvironment.IsDevelopment(); strictly that's environment, not configuration, but it's often confused with Debug.git grep -n "#if DEBUG\|Debug\.\|IsDevelopment".| 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.
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.volatile, Volatile.Read/Volatile.Write, or better, a CancellationToken.Interlocked, lock, or concurrent collections.GC.KeepAlive only as a last resort.-c Release.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.
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.
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.
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
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.
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 |
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.
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);
| 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 |
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.
| 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 |
dotnet test -c Release), ideally failing reliably before the fix. For races, run the scenario many times or under stress.Microsoft.VisualStudio.Threading.Analyzers) and trim analysers (IsTrimmable, EnableTrimAnalyzer) where relevant.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.
dotnet run (Debug): it stops after one second.dotnet run -c Release: it may never stop.DOTNET_TieredCompilation=0 and run the Debug build: does that change anything? (It shouldn't; Debug assemblies disable JIT optimisation.)private volatile bool _stopping; and rerun in Release.CancellationTokenSource and rerun.dotnet-dump collect -p <pid>, then clrstack -all and dumpobj on the worker to see _stopping already set.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.
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.
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".
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.
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.
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.
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.
Last updated 1 Nov 2026.