
How to Profile CPU and Memory in .NET Applications Using dotnet-counters
A service that answers in 40ms at 10am and takes two seconds by mid-afternoon has a story to tell. Logs usually mangle it, and by the time you attach a debugger the shape of the problem has changed. dotnet-counters gives you a plainer view: a live stream of CPU, garbage collector and thread pool numbers from a process that is already running, refreshed roughly once a second, with no code changes and no restart.
It takes about ninety seconds to install, and it is usually the first thing worth doing when a .NET service misbehaves in production. The trick is knowing which counters matter and what combinations of them actually mean.
What dotnet-counters Does, and What It Won't Do
Under the bonnet, dotnet-counters is a client for EventPipe, the diagnostics channel built into modern .NET. The runtime and your libraries publish counters through it, and the tool subscribes and prints them. Anything running .NET Core 3.0 or later can be watched this way — Windows, Linux or macOS, including apps inside containers.
What it is not is a profiler. You get process-level and runtime-level numbers, not call stacks or allocation sites. That is a feature, not a limitation. When an incident starts, the first useful question is whether you are chasing CPU, memory or blocking, and this answers it in a minute. Once you know which of the three it is, reach for dotnet-trace for CPU sampling or dotnet-gcdump for heap analysis.
Overhead is modest but not zero, especially if you request every counter at one-second intervals. On a busy box, watch the handful you care about and leave the rest alone.
Installing and Attaching in Under Two Minutes
- Install the tool globally with dotnet tool install --global dotnet-counters. If you already have it, dotnet tool update --global dotnet-counters.
- Confirm it runs: dotnet-counters --version.
- List the .NET processes on the machine: dotnet-counters ps. You get the process name and PID for each one.
- Attach to the one you care about: dotnet-counters monitor --process-id 12345. If you would rather skip the PID lookup, --name MyService matches on the process name. Press Ctrl+C to stop.
You need the same user account as the target process, or sufficient privileges. In containers, the simplest approach is to run the tool inside the same container; a sidecar sharing the diagnostics socket works too. Also check that DOTNET_EnableDiagnostics has not been set to 0 in the environment — that disables the diagnostics channel entirely and nothing will attach.
The Counters Worth Watching
The default monitor view is long. These three are the ones to read first:
- CPU usage (%). Process CPU relative to the whole machine, so on a four-core box a single pegged thread shows around 25%. Rising with traffic is normal; rising while traffic is flat is not.
- Working set (MB). Resident memory for the process. Compare it against your container memory limit before you start talking about leaks.
- GC heap size (MB). Managed memory currently held. Watch the shape over time rather than any single reading: growing between collections and settling after a Gen 2 pass is healthy; growing and never falling back is not.
Garbage collection signals
Add allocation rate (bytes per second), Gen 0/1/2 collection counts, time in GC (%), and large object heap size. A high allocation rate with frequent Gen 0 collections is the most common cause of "CPU is at 80% and we can't see why". Time in GC above a few per cent during ordinary traffic means the collector is doing real work on your behalf — usually because you are allocating heavily or holding a large heap.
Thread pool signals
Thread pool thread count, queue length and completed items tell you whether work is waiting. Queue length climbing while thread count stays flat is the classic signature of blocking on pool threads: synchronous I/O, .Result, .Wait(), or a sleep somewhere it should not be. The monitor lock contention counter often tells the same story from the locking side.
Exception count
Exceptions are expensive. A throw-and-catch inside a loop will show up here before it shows up anywhere else, and it will show up in CPU shortly afterwards.
Reading the Numbers Without Overreacting
A counter on its own means very little. Capture a baseline from a comparable load before you draw conclusions, then look for pairs of metrics moving together:
- CPU high, allocation rate high, Gen 0 collections frequent: allocation pressure. Look for per-request allocations — string building in loops, LINQ in hot paths, boxing, oversized buffers.
- CPU high, allocations modest: something else. Regex, serialisation, compression or genuinely CPU-bound work. Take a trace and look at the stacks.
- Gen 2 counts climbing, time in GC rising, heap failing to settle: take a gcdump and inspect what survives. Static collections, unbounded caches and event handlers keeping objects alive are the usual suspects.
- Queue length rising, thread count pinned: blocking. Hunt down synchronous I/O and sync-over-async calls.
- Exception count spiking alongside CPU: fix the exceptions first and re-measure. The CPU problem may disappear with them.
Narrowing the Output and Exporting Runs
Watching everything is noisy. Restrict the list with a filter: dotnet-counters monitor --process-id 12345 --counters System.Runtime[gen-2-gc-count,cpu-usage,gc-heap-size]. Adding System.AspNetCore.Hosting gives you requests per second, current requests and failed requests alongside the runtime counters, which is handy for lining up a metric spike with a traffic spike.
For anything you might need to show someone later, collect instead of monitor: dotnet-counters collect --process-id 12345 --counters System.Runtime,System.AspNetCore.Hosting --format csv --output counters.csv --duration 00:02:00. If the output scrolls past too quickly to read, slow it down with --refresh-interval 5. CSV captures are underrated: one before a fix and one after gives you a defensible answer to "did that actually help?", and the file sits nicely on the ticket.
Making It Part of the Routine
Pick five or six counters per service — CPU, working set, GC heap size, Gen 2 count, allocation rate and thread pool queue length covers most ground. Record what they look like under a representative load, and keep those numbers in your runbook next to the service's normal latency and error rate. When something changes, you will know within a minute whether you are looking at a memory problem, an allocation problem or a blocking problem, and which tool to open next.
Pair it with dotnet-trace for CPU investigations and dotnet-gcdump for heap ones, and the first ten minutes of an incident stop being guesswork. It is the cheapest diagnostic step available on .NET, and it is worth having in muscle memory before you need it.
Photo: AS_Photography / Pixabay
Designing Backpressure into Event-Driven Systems
Queues, rate limits and load shedding are the three levers that stop a busy producer overwhelming a slower...
Debugging Race Conditions with ThreadSanitizer: A Practical Walkthrough
ThreadSanitizer catches data races that only appear under load. Here is how to build with it, read its...




