Workstation GC: single heap, background GC on by default, non-concurrent option, defaults per app type, and choosing it for desktop, tools and dense containers.
Workstation GC uses a single heap and runs collections on the thread that triggered them (with a background GC thread for concurrent Gen 2 work). It aims for small memory footprint and short pauses rather than maximum throughput. It is the default for console, desktop and worker apps that do not use the Web SDK; ASP.NET Core apps default to Server GC. You enable it with <ServerGarbageCollector>false</ServerGarbageCollector>; background (concurrent) GC is on by default and can be turned off with <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>. Choose Workstation GC for desktop apps, CLI tools, and many small containers where memory density matters more than throughput.
"Workstation GC uses a single managed heap, and collections run on the thread that triggered them, plus one background thread for concurrent Gen 2 work. It's tuned for a small memory footprint and short pauses rather than raw throughput on many cores. It's the default for console, desktop and worker-style apps; ASP.NET Core apps default to Server GC. I'd choose Workstation GC for desktop apps and CLI tools, for batch jobs (possibly non-concurrent), and for services running as many small, low-traffic containers where memory per pod matters more than throughput. I always confirm the active mode at startup with
GCSettings.IsServerGC."
| Aspect | Workstation GC |
|---|---|
| Number of heaps | One |
| Who runs the GC | The thread whose allocation triggered it (normal priority) |
| Background Gen 2 | One dedicated background GC thread |
| Gen 0 budget | Smaller → more frequent, smaller GCs |
| Memory footprint | Lowest of the GC modes |
| Multi-core throughput | Lower than Server GC under heavy allocation |
| Allocation contention | Single heap shared by all threads (per-thread allocation contexts still avoid locks on the fast path) |
| Flavour | Setting | Behaviour | Good for |
|---|---|---|---|
| Background workstation (default) | ConcurrentGarbageCollection=true |
Gen 2 GCs mark concurrently; Gen 0/1 GCs can run during them | Desktop, interactive tools, small services |
| Non-concurrent workstation | ConcurrentGarbageCollection=false |
Every GC is blocking; slightly less overhead and memory (no background thread) | Batch jobs, build tools, where pauses don't matter |
| App type | Default GC |
|---|---|
| Console, WPF, WinForms, MAUI, plain worker services | Workstation, background |
ASP.NET Core (Microsoft.NET.Sdk.Web) |
Server GC (with DATAS in .NET 9+) |
| Process with only one CPU available (affinity mask or container CPU limit) | Workstation behaviour even if Server GC is requested |
| Where | Setting |
|---|---|
.csproj |
<ServerGarbageCollector>false</ServerGarbageCollector>, <ConcurrentGarbageCollection>true/false</ConcurrentGarbageCollection> |
runtimeconfig.json |
"System.GC.Server": false, "System.GC.Concurrent": true |
| Environment | DOTNET_gcServer=0, DOTNET_gcConcurrent=0 (override build settings at run time) |
| Verify | GCSettings.IsServerGC; GCSettings.LatencyMode = Interactive (background) or Batch (non-concurrent) |
| Workstation | Server (pre-DATAS) | Server with DATAS (.NET 9+) | |
|---|---|---|---|
| Heaps | 1 | One per core (by default) | Starts small, grows with load |
| GC threads | Triggering thread + 1 background | One dedicated GC thread per heap (+ background threads) | Adapts |
| Memory | Lowest | Highest | Closer to Workstation at low load |
| Throughput under heavy allocation | Lower | Highest | High when scaled up |
| Typical use | Desktop, tools, small pods | High-traffic APIs on dedicated cores | Default for web apps |
With DATAS in .NET 9+, Server GC's memory gap at low load is much smaller, so I measure both rather than assuming.
Our platform team ran [about 300] small internal APIs at [0.25 to 0.5] CPU and [256–512 MB] per pod, and node memory limited how many pods fit. I load-tested a representative set with Server GC and Workstation GC at realistic traffic: p50/p99 latency and CPU were statistically the same, but idle and steady-state memory was [about 40%] lower with Workstation GC. We set ServerGarbageCollector=false in a shared Directory.Build.props for services tagged low-traffic, logged GCSettings.IsServerGC at startup, and fitted [X] more pods per node. High-traffic services stayed on Server GC.
The interviewer checks you know GC modes, their defaults and trade-offs.
Green flags: single heap, background versus non-concurrent, defaults by SDK, configuration methods, container behaviour, measured choice.
Red flags: thinks there is only one GC mode; always picks Server GC; cannot say how to check the active mode.
In Workstation GC there are no dedicated GC worker threads for blocking collections. When an allocation exhausts the Gen 0 budget, that thread:
The background GC thread only does concurrent Gen 2 marking and sweeping. Consequence: on a many-core machine, a blocking GC uses one core to do the work while others wait. Server GC parallelises the same work across GC threads, which is why it wins on throughput with heavy allocation.
Workstation: |g0|..|g0|..|g0|..|g0|..|g0|.. small budget, frequent short GCs
Server: | g0 |........| g0 |....... bigger per-heap budgets, fewer GCs, more memory
Workstation's smaller Gen 0 budget (related to CPU cache sizes) keeps the heap small and each pause short, but you'll see more GCs per second for the same allocation rate.
| Background (concurrent) | Non-concurrent | |
|---|---|---|
| Gen 2 marking | Concurrent with the app | Blocking |
| Ephemeral GCs during Gen 2 | Allowed (foreground GCs) | N/A |
| Extra threads | 1 background GC thread | None |
| Write watch for concurrent marking | Needed (some overhead) | Not needed |
GCSettings.LatencyMode |
Interactive |
Batch |
For a CLI tool that runs for 20 seconds and exits, non-concurrent can be marginally faster and lighter.
Environment.ProcessorCount reflects the CPU limit (rounded up), not host cores.GCHeapHardLimit = max(20 MB, 75% of the container memory limit) when a limit exists.DOTNET_PROCESSOR_COUNT can override the CPU count the runtime sees (useful when CPU limits are unusual).| Knob | Effect |
|---|---|
GCConserveMemory (0–9) |
Trades CPU for more compaction and lower fragmentation |
GCHeapHardLimit / GCHeapHardLimitPercent |
Caps the GC heap |
GCgen0size |
Overrides the Gen 0 budget (rarely a good idea) |
TieredPGO, ReadyToRun |
Not GC, but affect startup memory and CPU in small pods |
Run the same load test twice, changing only DOTNET_gcServer:
| Metric | Source |
|---|---|
| p50/p95/p99 latency | Load tool (k6, bombardier) |
| CPU per request | Container metrics |
| Working set / GC heap size | dotnet-counters, container metrics |
| % time in GC, GC counts | dotnet-counters |
Pick the mode that meets latency SLOs at the lowest memory and CPU cost.
| Version | Change |
|---|---|
| .NET Framework 4 | Background GC replaced concurrent GC for Workstation |
| .NET Core 3.0 | Container-aware limits, GCHeapHardLimit |
| .NET 6 | GCConserveMemory (also available in .NET Framework 4.8) |
| .NET 7 | Regions on 64-bit for both modes |
| .NET 9 | DATAS default for Server GC narrows the memory gap |
| .NET 10 | Same Workstation model; still the smallest-footprint option |
IsServerGC, LatencyMode, Gen 0 count and committed memory.DOTNET_gcServer=1 and compare Gen 0 count and committed MB.DOTNET_gcConcurrent=0 and confirm LatencyMode becomes Batch.--cpus=1 --memory=256m and with --cpus=4; compare ProcessorCount, TotalAvailableMemoryBytes and heap sizes.bombardier -c 50 -d 60s, and chart latency and memory side by side.using System.Runtime;
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}");
Console.WriteLine($"Latency mode: {GCSettings.LatencyMode}"); // Interactive = background workstation, Batch = non-concurrent
Console.WriteLine($"CPU count: {Environment.ProcessorCount}");
int gen0 = GC.CollectionCount(0);
for (int i = 0; i < 5_000_000; i++) _ = new byte[100];
GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"Gen 0 GCs: {GC.CollectionCount(0) - gen0}");
Console.WriteLine($"Heap size: {info.HeapSizeBytes / 1024 / 1024} MB");
Console.WriteLine($"Committed: {info.TotalCommittedBytes / 1024 / 1024} MB");
Console.WriteLine($"Memory limit: {info.TotalAvailableMemoryBytes / 1024 / 1024} MB");
<PropertyGroup>
<ServerGarbageCollector>false</ServerGarbageCollector>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
What happens: every API gets Server GC because "it's a server". Why it is wrong: for small, low-traffic pods it uses more memory with no latency benefit. Do instead: measure memory and latency under realistic load and choose per service.
What happens: ConcurrentGarbageCollection=false set to "speed things up".
Why it is wrong: every Gen 2 GC becomes blocking, so UI freezes and request pauses get longer.
Do instead: keep background GC on unless it's a batch job where pauses don't matter.
What happens: a leftover DOTNET_gcServer=1 in a Helm chart silently changes behaviour.
Why it is wrong: the .csproj no longer reflects reality.
Do instead: log GCSettings.IsServerGC and LatencyMode at startup; keep GC config in one place.
What happens: candidates say Workstation GC is always blocking. Why it is wrong: background (concurrent) Gen 2 is the default in Workstation too. Do instead: distinguish background workstation from non-concurrent workstation.
What happens: Server GC is configured but the pod has a one-core limit.
Why it is wrong: the runtime falls back to single-heap behaviour.
Do instead: verify with GCSettings.IsServerGC and heap count; size CPU limits deliberately.
What happens: idle memory is compared and a decision is made. Why it is wrong: the difference under load (GC frequency, latency) is what matters. Do instead: realistic load tests with latency percentiles and memory.
What happens: Workstation GC in a tight container still OOMs.
Why it is wrong: native memory and the 75% default hard limit also matter.
Do instead: look at GC heap vs process memory vs limit; set GCHeapHardLimitPercent consciously if needed.
GCgen0size to mimic Server GCWhat happens: Gen 0 is made huge in Workstation mode to cut GC count. Why it is wrong: longer pauses and more memory, losing Workstation's benefits. Do instead: choose the mode that fits, then reduce allocations.
What happens: a service grows from low to high traffic but keeps Workstation GC. Why it is wrong: throughput suffers as allocation rate and cores increase. Do instead: revisit GC mode as part of capacity reviews; with .NET 9+, Server GC + DATAS may suit both.
What happens: advice from older articles ("Server GC uses one heap per core and lots of memory") applied to .NET 9/10. Why it is wrong: DATAS starts with fewer heaps and adapts. Do instead: re-measure on the current runtime before switching modes.
GC mode is a capacity planning decision.
| Deployment | Usual choice |
|---|---|
| Desktop and CLI | Workstation, background |
| Batch job, pauses irrelevant | Workstation, non-concurrent |
| Small pods, many replicas | Workstation, or Server with DATAS |
| High-throughput API on several cores | Server GC |
Seniors log the GC mode at startup and include memory per replica in cost discussions.
Server GC creates one heap per logical CPU (limited by affinity and container CPU limits) with dedicated, high-priority GC threads that collect in parallel. Budgets are larger, so GCs are less frequent and throughput is high, but memory use is higher. It is the default for ASP.NET Core. DATAS (Dynamic Adaptation To Application Sizes), opt-in in .NET 8 and on by default with Server GC since .NET 9, starts with fewer heaps and adjusts the heap count and budgets to the live data size and load, so a lightly loaded service uses far less memory. In containers, the GC honours CPU and memory limits; GCHeapHardLimit (or ...Percent), GCHeapCount and GCConserveMemory give finer control.
The .NET GC divides the managed heap into Gen 0 (new objects), Gen 1 (a buffer for objects that survived one GC) and Gen 2 (long-lived objects, plus the LOH and POH logically). It relies on the generational hypothesis: most objects die young. A Gen 0 GC only examines young objects, so its cost is proportional to the few survivors, not to the whole heap. Survivors are promoted to the next generation. References from old objects to young ones are tracked by a write barrier and card table so a young GC does not have to scan Gen 2. Collecting generation N also collects all younger ones. Since .NET 7 the heap is organised in regions rather than large segments.
How to tell it (STAR)
Our platform ran [about 300] small APIs and memory limited how many fit per node. I tested Workstation GC against Server GC, saw no latency change for those services and switched them, cutting memory per pod by [about 40%].
Last updated 31 Oct 2026.