.NET Runtime Configuration Knobs You Should Know

The .NET runtime exposes dozens of configuration settings that control garbage collection, threading, JIT compilation, globalisation, and diagnostics. Most applications never need to touch them — the defaults are well-tuned. But when you're running in constrained environments, debugging production issues, or squeezing out performance, knowing which knobs to turn is invaluable.

Where to set them

Runtime configuration can be set in three places, in order of precedence:

1. Environment variables

DOTNET_GCServer=1
DOTNET_EnableDiagnostics=0

2. runtimeconfig.json

This file sits alongside your published binary. The template is generated from your project's runtimeconfig.template.json:

data.json
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true,
      "System.GC.Concurrent": true,
      "System.GC.HeapHardLimit": 268435456
    }
  }
}

Set properties in your .csproj to populate the template:

config.xml
<PropertyGroup>
    <ServerGarbageCollection>true</ServerGarbageCollection>
    <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

<ItemGroup>
    <RuntimeHostConfigurationOption
        Include="System.GC.HeapHardLimit"
        Value="268435456" />
</ItemGroup>

3. MSBuild properties

Some settings have dedicated MSBuild properties (like ServerGarbageCollection above) that write to the runtimeconfig automatically.

Garbage collection

Server vs workstation GC

data.json
{
  "configProperties": {
    "System.GC.Server": true
  }
}

Server GC allocates a heap and a dedicated GC thread per logical processor. It's optimised for throughput in multi-threaded server applications. Workstation GC uses a single heap and GC thread, optimised for responsiveness in client applications.

ASP.NET Core enables Server GC by default. Console applications and worker services default to Workstation GC — consider switching to Server GC for high-throughput background processors.

Heap hard limit

In containerised environments, set a memory ceiling:

data.json
{
  "configProperties": {
    "System.GC.HeapHardLimit": 268435456
  }
}

The value is in bytes (256 MB in this example). When the GC hits this limit, it performs more aggressive collections rather than requesting more memory from the OS. This prevents container OOM kills.

Alternatively, use a percentage of the container's memory limit:

data.json
{
  "configProperties": {
    "System.GC.HeapHardLimitPercent": 75
  }
}

GC regions (default from .NET 8)

From .NET 8, the GC uses a regions-based memory model by default instead of segments. Regions are smaller, more flexible memory units that reduce fragmentation. You can disable it if you encounter issues:

data.json
{
  "configProperties": {
    "System.GC.Regions": false
  }
}

But there's rarely a reason to — regions are a strict improvement for most workloads.

Threading

Thread pool configuration

The thread pool's minimum thread count can be set to avoid slow ramp-up under burst load:

Example.cs
ThreadPool.SetMinThreads(workerThreads: 100, completionPortThreads: 100);

Or via configuration:

data.json
{
  "configProperties": {
    "System.Threading.ThreadPool.MinThreads": 100
  }
}

// WARNING

Setting this too high wastes memory and can actually reduce performance. Only increase it if you've confirmed thread pool starvation via diagnostics.

Thread pool upper limit

data.json
{
  "configProperties": {
    "System.Threading.ThreadPool.MaxThreads": 500
  }
}

Again, rarely needed — the thread pool's hill-climbing algorithm is generally better at finding the right thread count than a static configuration.

Diagnostics

Disabling diagnostics entirely

For high-security or ultra-lean deployments:

DOTNET_EnableDiagnostics=0

This disables the diagnostic port, EventPipe, and debugger attach. Useful in production containers where you don't want external tools connecting to the process.

Event pipe providers

Enable specific diagnostic providers at startup:

DOTNET_DiagnosticPorts=*:connect

Or configure event sources in runtimeconfig.json for always-on diagnostics.

Globalisation

Invariant mode

If your application doesn't need culture-specific formatting:

data.json
{
  "configProperties": {
    "System.Globalization.Invariant": true
  }
}

This eliminates the dependency on ICU libraries, which is particularly useful for Alpine Linux containers and Native AOT deployments where the ICU data adds significant size.

config.xml
<!-- Or via MSBuild -->
<PropertyGroup>
    <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

Caution: Invariant mode changes sorting, string comparison, and date/number formatting behaviour. Test thoroughly if your application handles user-facing text in multiple locales.

Predefined cultures only

A middle ground — load only the cultures you actually need:

data.json
{
  "configProperties": {
    "System.Globalization.PredefinedCulturesOnly": true
  }
}

HTTP and networking

HTTP/3 support

Enable HTTP/3 (QUIC) for HttpClient:

data.json
{
  "configProperties": {
    "System.Net.SocketsHttpHandler.Http3Support": true
  }
}

Connection pool limits

DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_MAXCONNECTIONSPERSERVER=100

This caps the number of concurrent connections HttpClient opens to a single server.

Practical advice

  1. Start with defaults. The .NET team benchmarks extensively. Override only when you have data showing a problem.
  2. Use environment variables in containers. They're easy to change without rebuilding.
  3. Use runtimeconfig for deployable defaults. Bake sensible settings into your published output.
  4. Monitor before and after. Use dotnet-counters or Application Insights to verify that a configuration change actually helps.

Wrapping up

Runtime configuration knobs are power-user tools. The GC settings (server mode, heap limits) are the ones you'll reach for most often, followed by globalisation invariant mode for container deployments. For everything else, the defaults are excellent — but it's good to know the levers exist when you need them.