When a .NET app chews through 100% CPU in production, you feel it immediately — latency spikes, queue backlogs, pager noise. I’ve chased these gremlins at 2 AM more times than I care to count, usually with a mug of coffee that went cold an hour ago. The trigger is almost never obvious. A single thread pool thread that decided to go rogue. A loop that looks innocent until it runs three million times. The garbage collector trying to clean up a mess that never stops growing. This article lays out the exact sequence I follow to track down and kill high-CPU problems on live systems, using tools that already ship with Windows and the .NET SDK.

Initial Triage: Is This a Real Spike or a Symptom?
Before you fire up memory dump collectors and ETW sessions, make sure the CPU burn is sustained and not just a transient wave from a legitimate workload. Open Task Manager or Process Explorer on the box and watch w3wp.exe — or your custom host process — for at least 30 to 60 seconds. Jot down the exact PID and the CPU trend. If the spike lines up perfectly with a known deployment or a scheduled batch job, you might already have your smoking gun. Otherwise, it’s time to grab a diagnostic snapshot.
First, confirm the .NET CLR version. Run dotnet --info or peek at the process modules in Process Explorer. The gap between .NET Framework 4.8 and .NET 6+ dictates which tooling will actually work. For .NET Core and later, dotnet-counters and dotnet-trace are your bread and butter. For .NET Framework, you’ll be spending quality time with PerfView and DebugDiag.
Capturing a Diagnostic Trace with PerfView
PerfView is still the heavyweight champion for .NET CPU analysis on Windows — free, no installer, and it hooks into ETW (Event Tracing for Windows) to grab stack samples without touching the target process. Here’s how to start a collection against a misbehaving PID:
- Launch PerfView.exe as Administrator.
- Click Collect → Collect.
- Tick CPU Samples and Thread Time.
- Bump the circular buffer to at least 256 MB so you don’t drop events under load.
- Enter the PID of the target process and collect for about 60 seconds.
Stop the trace and let PerfView chew on the ETL file. Open the CPU Stacks view and group by Inc % — that’s inclusive CPU time. The methods that sit at the top are burning the most wall-clock time when you include their children. Then look at the Exc % column. High exclusive time means the method is doing the actual work itself, not just calling other things. Those leaf functions are where the heat is.
One trap that catches everyone: seeing System.String.Concat at the top of the stack and blaming string allocations. Don’t. Walk the call tree back to your code. The real villain might be a logging call that serializes a 2 MB object on every iteration of a tight loop.

Using dotnet-trace for .NET Core and .NET 5+
For modern .NET, dotnet-trace gives you a lightweight, cross-platform alternative that doesn’t require a Windows box. Install it once as a global tool:
dotnet tool install --global dotnet-trace
To grab a 30-second CPU trace on process ID 1234:
dotnet-trace collect --process-id 1234 --duration 00:00:30 --providers Microsoft-DotNETCore-SampleProfiler
The .nettrace file opens in PerfView or SpeedScope. I usually convert it to a SpeedScope flame graph right away:
dotnet-trace convert --format speedscope -i trace.nettrace -o trace.speedscope.json
Flame graphs make it embarrassingly easy to pick out wide, deep stacks that are hogging the CPU. Patterns like accidental recursion, sync-over-async blocking, or a third-party library that calls a reflection-based serializer inside a hot path jump off the screen.
Identifying Common Patterns
Thread Pool Starvation and Synchronous Blocking
One of the classic ASP.NET high-CPU patterns is thread pool starvation triggered by synchronous calls on top of async methods — .Result, .Wait(), and friends. The thread pool notices work piling up and injects extra threads to compensate. That injection burns CPU in context switching and spin waits. In PerfView, you’ll see an oversized chunk of time in System.Threading.ThreadPoolWorkQueue.Dispatch and System.Threading.Tasks.Task.SpinWait. The fix is straightforward but sometimes painful: propagate async all the way up the call chain, and if you’re writing library code, sprinkle in ConfigureAwait(false) where it makes sense.
Garbage Collector Pressure
High CPU doesn’t always come from your code. Sometimes the GC is the one sweating. This is especially true in Server GC mode where multiple heaps compete. If you see clr!WKS::GCHeap::GarbageCollectGeneration or coreclr!SVR::gc_heap::gc1 dominating the stacks, your app is allocating like there’s no tomorrow. Use dotnet-counters to keep an eye on GC health:
dotnet-counters monitor --process-id 1234 System.Runtime
Watch the % Time in GC counter. If it’s north of 10% during the spike, you’ve got an allocation problem. PerfView’s GC Heap Alloc Ignore Free view will show you the exact types and call stacks doing the damage.
Infinite or Near-Infinite Loops
Every now and then, the trace is so simple it stings a little. A while (true) that forgot its exit condition. A LINQ expression that materializes a giant collection on every iteration. In the CPU stacks, you’ll see exclusive time cluster around a single MoveNext or loop body. The Exc % will be 80–90%, and the Inc % will match because nothing else gets a turn. Don’t overthink it — just find that loop and fix the exit logic.

Live Debugging with WinDbg and SOS
When a trace isn’t giving you the full story, it’s time to attach a debugger. Ideally, you do this on a staging environment or during a low-traffic window in production. Load the SOS extension:
.loadby sos clr [for .NET Framework]
.loadby sos coreclr [for .NET Core]
Dump all managed threads and their CPU time in one shot:
!threads -live
Look for any thread with a fat number in the CPU Time column. Switch to it with ~[threadID]s and check the managed stack:
!clrstack
If the stack is sitting inside a long-running operation, set a breakpoint or inspect locals with !dso. For .NET 8 and later, the !dumpstack -ee command blends native and managed frames — helpful when the CPU spike originates from P/Invoke or a runtime bug.
Correlating with Event Logs and Performance Counters
CPU spikes rarely throw a party by themselves. Check Windows Event Logs for .NET Runtime warnings — Event IDs 1025 and 1026 are the usual suspects for thread pool exhaustion or app errors. Fire up Performance Monitor and add these counters:
.NET CLR Memory\% Time in GC.NET CLR LocksAndThreads\Contention Rate / secProcess\% Processor Timefor the specific instance
If the contention rate spikes right alongside CPU, threads are fighting over locks and burning cycles spinning. Revisit your locking strategy. For read-heavy workloads, ReaderWriterLockSlim or SemaphoreSlim often outperform raw Monitor.Enter.
Preventive Measures for Production
Once you’ve patched the immediate wound, set up tripwires so it doesn’t happen again. Add custom performance counters around the hot paths you just fixed and wire them into your monitoring stack’s alerting. For ASP.NET apps, enable EventCounter telemetry through Microsoft.Extensions.Diagnostics.Metrics and pipe it to Application Insights or Prometheus. A sudden deviation in cpu-usage or threadpool-queue-length can fire an alert before customers start opening tickets.
Regular load testing catches regressions early. Tools like Bombardier or NBomber let you profile each test run. The difference between a 50% CPU spike at 500 requests per second and a 100% spike at 2,000 is often just concurrency — better to find it on a Wednesday afternoon than a Saturday night.
Frequently Asked Questions
What is the fastest way to identify the thread causing a CPU spike in .NET?
Confirm the spike with dotnet-counters, grab a 30-second trace using dotnet-trace, and open the flame graph in SpeedScope. The widest stack at the top of the graph is your prime suspect. If you have to debug live, !threads -live in WinDbg shows per-thread CPU time instantly.
Why does my .NET application spike CPU during garbage collection?
Server GC spins up dedicated threads to compact heaps, and Gen 2 collections in particular can get expensive. This happens when your allocation rate is high and objects keep surviving into older generations. Keep an eye on % Time in GC and use PerfView’s allocation view to cut unnecessary allocations — especially large object heap (LOH) usage, which triggers full-blocking collections that stop the world.
Can async/await cause high CPU in .NET?
Async/await itself isn’t a CPU hog, but misusing it causes thread pool starvation. Blocking on async work with .Result or .Wait() forces the pool to inject extra threads, and that injection burns CPU through context switching and spin waits. Bubble async calls all the way to the entry point and avoid sync-over-async patterns.
How do I capture a CPU trace on a production server without installing tools?
Copy PerfView.exe to the server — it’s a single binary with no installation. For .NET Core, you can deploy dotnet-trace as a self-contained app. Both tools add minimal overhead and can be kicked off remotely through PowerShell or a scheduled task during quieter hours.