Top 10 Ways to Reduce .NET Memory Usage in Kubernetes

If your .NET pods are each using 1–2 GB of RAM, you may be leaving a lot of pod density — and money — on the table. Here are the first things I would look at.

1. Set Realistic Kubernetes Memory Limits

Do not let every pod believe it has access to the whole node.

1
2
3
4
5
6
7
resources:
  requests:
    memory: "512Mi"
    cpu: "100m"
  limits:
    memory: "768Mi"
    cpu: "1000m"

This gives the .NET runtime better boundaries and helps Kubernetes schedule pods more predictably. The .NET GC specifically looks at the container’s memory limit (not the request) to decide how much heap it’s allowed to use, so an unset or overly generous limit is what lets the managed heap grow far larger than you intended.

2. Consider Workstation GC for Smaller Pods

Server GC is great for throughput, but it can consume more memory, especially when you run many small pods per node.

1
2
3
env:
  - name: DOTNET_gcServer
    value: "0"

For smaller ASP.NET Core services, Workstation GC may be a better fit.

.NET 9/10 note: Since .NET 8, there’s a third option besides ‘pick Workstation or Server GC’: DATAS (Dynamic Adaptation To Application Sizes). It keeps you on Server GC’s throughput model but grows and shrinks the number of heaps as load changes, so the heap stays roughly proportional to your live data instead of the machine’s core count. DATAS was opt-in in .NET 8 (DOTNET_GCDynamicAdaptationMode=1) and has been enabled by default since .NET 9, including .NET 10. In practice, many services that used to need DOTNET_gcServer=0 to stay small can now stay on Server GC and get both good throughput and a heap that shrinks back down after a burst — worth re-measuring before you assume you still need Workstation GC on .NET 9+.

3. Cap the .NET GC Heap

You can tell .NET how much memory the managed heap is allowed to use.

1
2
3
env:
  - name: DOTNET_GCHeapHardLimitPercent
    value: "3C"

This leaves headroom for native memory, threads, thread-local storage (TLS), sockets, loaded assemblies, and other non-managed memory.

Two things worth knowing before you set this:

  • Environment variable values are hexadecimal, not decimal. runtimeconfig.json/MSBuild settings (System.GC.HeapHardLimitPercent) take a decimal value, but the environment variable equivalent does not. To cap the heap at 60%, the env var value is 3C (0x3C = 60 decimal) — setting it to the literal string 60 is parsed as 0x60, i.e. 96%, which defeats the purpose. See the heap hard limit percent reference for the full decimal-vs-hex table.
  • The GC already defaults this to 75% inside a container. When .NET detects it’s running under a memory limit (a cgroup limit, which is how Kubernetes enforces pod limits), GCHeapHardLimitPercent already defaults to 75% of that limit with no configuration at all. Setting your own value is about tightening that default further for a memory-sensitive service, not turning the behavior on for the first time.

.NET 10 note: If a single hard-limit percentage isn’t precise enough, .NET 10 adds DOTNET_GCDGen0GrowthPercent, DOTNET_GCDGen0GrowthMaxFactor, and DOTNET_GCDGen0GrowthMinFactor to tune how aggressively DATAS grows its gen0 allocation budget as your live data set grows. These are worth reaching for only if you’ve already measured that DATAS’s default heap-size adaptation is too eager or too conservative for a specific service — see the DATAS section of the GC config docs for the exact formula.

4. Measure Managed vs. Native Memory

Before refactoring, find out where the memory is actually going.

1
2
3
4
5
kubectl top pod -n my-namespace
dotnet-counters ps                     # find the process ID to target below
dotnet-counters monitor -p <pid> System.Runtime
dotnet-gcdump collect -p <pid>
dotnet-dump collect -p <pid>

If your working set is 1.5 GB but your managed heap is only 300 MB, GC tuning alone will not fix the problem — you’re looking at native memory (unmanaged buffers, native library allocations, thread stacks, or similar), not something a GC heap setting can constrain.

These are the .NET diagnostic CLI tools, and they work directly against a process inside a running container, but you generally need to run them from inside the container (kubectl exec) or via a dotnet-monitor sidecar rather than from your workstation. See Collect diagnostics in Linux containers for the supported patterns.

5. Reduce ASP.NET Core Allocation Pressure

Look for common allocation hot spots:

  • large request or response buffers
  • unnecessary .ToList() calls
  • huge JSON payloads
  • excessive logging objects
  • large objects stored in HttpContext.Items
  • middleware that buffers when it could stream

Small per-request allocations become big memory pressure under load. See Memory management and patterns in ASP.NET Core for the framework-level guidance behind this.

Detecting “Large” and “Excessive” at Runtime

You don’t have to wait for a profiling session to find these — instrument the hot spots so they surface themselves. A quick version of this is exactly what it sounds like: after .ToList(), check .Count, and log if it’s above a threshold. That works, but two problems show up once it’s running in production:

  • You have to guess the threshold up front, with no data on what “normal” looks like, so you either pick something too low (log spam) or too high (misses the early warning).
  • A genuinely systemic problem — not a rare outlier — logs on every single request, which turns your “for later performance tuning” signal into its own allocation and I/O cost.

A better default is a Histogram<T> from System.Diagnostics.Metrics, recording the size on every call with no threshold at all. It gives you the full distribution (p50/p95/max) through dotnet-counters or an OpenTelemetry/Prometheus pipeline, so you pick the threshold after looking at real data instead of guessing, and there’s no per-request log volume to worry about. Reserve an actual log line for the case where you want a concrete example to jump to (a trace ID, a specific customer), gated by a simple rate limiter so a systemic problem doesn’t spam your log sink:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static readonly Meter Meter = new("MyApp.Allocations");
static readonly Histogram<int> OrderListSize = Meter.CreateHistogram<int>("orders.list.size");

var orders = await db.Orders.AsNoTracking() /* ... */ .ToListAsync();
OrderListSize.Record(orders.Count);

if (orders.Count > 5_000 && _logGate.ShouldLog())
{
    _logger.LogWarning("Large order list returned: {Count} rows for customer {CustomerId}",
        orders.Count, customerId);
}

_logGate is just a small thread-safe “once per interval” guard, so the warning fires at most, say, once a minute no matter how many requests trip the threshold:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
sealed class LogRateGate(TimeSpan interval)
{
    private long _nextAllowedTicks;

    public bool ShouldLog()
    {
        var now = DateTime.UtcNow.Ticks;
        var next = Interlocked.Read(ref _nextAllowedTicks);
        if (now < next) return false;
        return Interlocked.CompareExchange(ref _nextAllowedTicks, now + interval.Ticks, next) == next;
    }
}

The trade-off: the histogram only pays off once something is actually collecting and querying it (OpenTelemetry/Prometheus/dotnet-counters), so it’s more setup than a bare if check. For a one-off investigation, just doing the threshold-and-log check directly is still fine — reach for the metric when this is a pattern you want visibility into on an ongoing basis, not just once.

6. Tune EF Core Queries

EF Core can quietly consume a lot of memory if you load and track more than you need.

Use no-tracking queries for read-only paths:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var orders = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .Select(o => new OrderSummaryDto
    {
        Id = o.Id,
        Total = o.Total,
        CreatedAt = o.CreatedAt
    })
    .ToListAsync();

Prefer projection over loading full entity graphs — it reduces how much of each row’s data you materialize. But projection alone doesn’t guarantee no tracking, and the two are easy to conflate: by default (i.e. without .AsNoTracking()), EF Core still tracks any entity-typed instances it finds inside a projection, even when the top-level result is a DTO or anonymous type — tracking is only skipped when the projected shape contains zero entity types, which is true of OrderSummaryDto above since it copies out scalars only. .AsNoTracking() itself isn’t selectively bypassed by an embedded entity — it disables tracking for the whole query.

The real risk is someone copying this projection pattern into a new query, forgetting to add .AsNoTracking(), and that new projection happening to embed a real entity (e.g. Customer = o.Customer) instead of just scalars — tracking would then kick in by default, silently. Keep .AsNoTracking() on every read query regardless of what it projects — it’s free when the shape is scalar-only, and it’s the thing actually standing between you and that default.

The more durable fix, if most of your queries are read-only, is to flip the default at the context level instead of relying on remembering .AsNoTracking() per query. For an ASP.NET Core service, that’s a chained call where the context is already registered in Program.cs:

1
2
3
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

(For a context that configures its own connection instead of being registered via DI, the same call goes in an OnConfiguring override on the DbContext subclass instead.)

Then opt in to tracking explicitly with .AsTracking() only on the queries that actually mutate entities. This is EF Core’s own recommended pattern for read-heavy contexts, and it removes the “did someone forget AsNoTracking()” question structurally rather than relying on code review or an analyzer to catch it — there isn’t a well-maintained Roslyn analyzer for this specific gap in EF Core today. See Tracking vs. No-Tracking Queries for the full behavior difference — including the “Tracking and custom projections” and “Configuring the default tracking behavior” sections — and AsNoTrackingWithIdentityResolution when you need no-tracking without duplicate entity instances.

7. Watch Thread Count

Every thread has stack memory. Too many threads can inflate memory even when your managed heap looks reasonable.

Check:

1
dotnet-counters monitor -p <pid> System.Runtime

Watch for:

  • high ThreadPool thread count
  • blocking I/O
  • .Result
  • .Wait()
  • sync-over-async
  • long-running background workers

8. Use Smaller Container Images

Smaller images will not magically fix a bloated process, but they help reduce baseline footprint and attack surface.

1
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled

Just be careful with globalization, time zones, native dependencies, and diagnostic tooling when using chiseled or distroless images — specifically:

  • Globalization. Chiseled images ship without ICU, and only work with apps configured for globalization-invariant mode (<InvariantGlobalization>true</InvariantGlobalization> in the project file). Skip that, and on .NET 6+ any attempt to create a non-invariant culture — CultureInfo.GetCultureInfo("fr-FR"), culture-aware string.Compare, currency/date formatting for a specific locale — throws CultureNotFoundException instead of silently doing the wrong thing. If your app genuinely needs culture-aware formatting or sorting, use the *-chiseled-extra variant (which includes ICU) instead of forcing invariant mode.
  • Time zones. Same root cause: no tzdata means TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time") (or any IANA/Windows zone other than UTC) throws TimeZoneNotFoundException. Either store and compute everything in UTC (usually the right call for a service anyway) or use the *-chiseled-extra variant if you must resolve local time zones in-process.
  • Native dependencies. There’s no package manager in the image, so anything your app P/Invokes into that isn’t already baked in — libgdiplus for System.Drawing, an ODBC driver, a native SDK — can’t be apt-get install-ed at runtime or even for a quick test. It has to be copied in during the Docker build stage, or you fall back to a non-chiseled base for that service.
  • Diagnostic tooling. No shell and no package manager also means no kubectl exec -it <pod> -- /bin/sh, and no installing dotnet-counters/dotnet-trace/dotnet-dump inside the container on demand — which directly affects the workflow in item 4 above. On a chiseled image, reach for a dotnet-monitor sidecar (attaches to the app’s diagnostics port without needing a shell in the target container) or kubectl debug --target to inject a temporary ephemeral container that shares the pod’s process namespace and brings its own tools. The same gap applies to exec-type liveness/readiness probes that shell out to curl — those need httpGet or tcpSocket probes instead, since those are performed by the kubelet from outside the container.

.NET 9/10 note: The chiseled base moved from Ubuntu 22.04 (“jammy”) to 24.04 (“noble”) starting with .NET 9 — use 9.0-noble-chiseled or 10.0-noble-chiseled. Also, starting with .NET 10, the default (non-chiseled) tags switched from Debian to Ubuntu, so a plain 10.0 tag now means Ubuntu Noble rather than Debian — see Default .NET container tags now use Ubuntu. If your goal is a smaller steady-state working set rather than just image size, it’s also worth evaluating the *-chiseled-aot images with Native AOT: no JIT means no JIT-related memory overhead and a smaller managed footprint, at the cost of some diagnostics tooling and dynamic-code scenarios. See .NET container images for the full tagging scheme.

9. Split Fat Pods from Thin Pods

Do not make every pod pay for the worst-case workload.

Separate:

  • normal API traffic
  • background workers
  • reporting/export jobs
  • image or document processing
  • scheduled batch jobs

This lets your main web pods stay small while memory-heavy work runs in purpose-built deployments.

10. Plan Around Pod Density, Not VM Size

A 64 GiB node does not give you 64 GiB for app pods. Leave room for the OS, kubelet, container runtime, logging agents, monitoring agents, DaemonSets, and eviction buffers.

A rough planning model:

Pod Limit Approx. Pods per 64 GiB Node
2 GiB ~25
1 GiB ~50
768 MiB ~65
512 MiB ~95

For ordinary ASP.NET Core services, start by trying to get typical pods into the 512–768 MiB range, then isolate the services that truly need more. See Resource Management for Pods and Containers and Assign Memory Resources to Containers and Pods for how Kubernetes actually enforces these limits at the kubelet level.

A Starting Configuration

For a representative ASP.NET Core service, maybe start with something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
resources:
  requests:
    memory: "384Mi"
    cpu: "100m"
  limits:
    memory: "768Mi"
    cpu: "1000m"

env:
  - name: DOTNET_gcServer
    value: "0"
  - name: DOTNET_GCHeapHardLimitPercent
    value: "3C"

Then load test it, watch GC behavior, and roll it out service-by-service.

Load Testing It Locally

Don’t trust a memory config until you’ve watched it under load — GC tuning that looks fine at idle can behave very differently once allocations ramp up. You don’t need a shared staging cluster to do this credibly; the important constraint to reproduce is the memory ceiling, and that’s easy to recreate on a laptop.

1. Reproduce the constraint, not just the code. Run the same image with the same cgroup memory limit the pod will have in production, so the .NET GC sees the same “physical memory” your cluster will give it:

1
2
3
4
5
docker run --rm -p 8080:8080 \
  --memory=768m --memory-swap=768m \
  -e DOTNET_gcServer=0 \
  -e DOTNET_GCHeapHardLimitPercent=3C \
  my-service:latest

If kind or minikube is available, applying the actual Deployment manifest (with its real resources.limits) is even more faithful, since it also exercises the kubelet’s OOM behavior instead of just Docker’s.

2. Generate load with a local tool. Two well-documented options that don’t require any shared infrastructure:

  • k6 — a scriptable, language-agnostic load generator with an official Docker image, so it needs no local install: docker run --rm -i grafana/k6 run - <script.js. A good default when your service isn’t .NET-only, or when you want the load script decoupled from the app.
  • NBomber — a .NET-native load-testing library (a NuGet package) that lets you write load scenarios in C#/F# alongside the rest of your codebase, with an HTML report generated per run. A good default when you want load tests versioned and run the same way as your other .NET tests or CI jobs.

Either way, run a ramping profile (light → peak → hold → ramp down) rather than a flat load. The ramp-down is what actually tells you whether memory comes back down — which is the entire point of testing Workstation GC, DATAS, and heap-limit choices in the first place.

3. Watch memory and GC behavior while the test runs, not just after:

1
2
docker stats                                   # or: kubectl top pod -n my-namespace
dotnet-counters monitor -p <pid> System.Runtime

Watch gen-0-size/gen-1-size/gen-2-size, % Time in GC, and allocation rate alongside your load tool’s p95/p99 latency. Take a dotnet-gcdump snapshot near peak load and another a minute after the ramp-down completes — a heap that doesn’t shrink back down between bursts points at your GC settings (or a leak), not your load profile.

4. Compare configurations against the same load profile. Re-run the identical k6/NBomber script against each candidate (Workstation GC, Server GC, Server GC with DATAS, with and without a heap hard limit) so the results are comparable, and pick the configuration that holds the memory ceiling at peak without regressing p95 latency or triggering an OOMKill.

Summary

Most of the memory in an oversized .NET pod isn’t a mystery once you measure it — it’s usually some combination of an unconstrained GC heap, a generous container limit nobody revisited, EF Core tracking more than it needs to, or too many threads doing blocking I/O. The highest-leverage, lowest-risk changes are usually the ones at the top of this list: set a real Kubernetes memory limit, let the GC know about it (GCHeapHardLimitPercent, mindful of the hex gotcha), and measure managed vs. native memory before changing anything else. Because DATAS is on by default in .NET 9 and 10, several of these problems are smaller than they used to be — it’s worth re-measuring an existing .NET 8 service after an upgrade before assuming you still need the same tuning. Whatever you change, load test it locally against the real memory limit before it goes anywhere near production; a config that looks fine at idle can OOM under a burst you never simulated.

References

Kubernetes resource management

.NET GC and runtime configuration

Diagnostics

EF Core

Container images

Load testing