Skip to main content

.NET Performance Profiling: dotTrace & PerfView Guide

Learn .NET performance profiling with dotTrace and PerfView. Find CPU, memory, and async bottlenecks in C# apps — step-by-step tutorial with code.

Why .NET Performance Profiling Beats Guessing Every Time

Every developer has been there: the app is slow, someone on the team says "it's probably the database," someone else blames the ORM, and an afternoon disappears into changing code that was never the problem. .NET performance profiling replaces that guesswork with measurement. Instead of asking "what do we think is slow?", a profiler answers "here is exactly where your CPU time, memory allocations, and wall-clock time went." In this guide, you'll learn how to find and fix real bottlenecks in C# applications using two of the best tools in the ecosystem: JetBrains dotTrace and Microsoft's free PerfView.

The reason profiling matters so much is a hard statistical truth: performance problems are almost never evenly distributed. In most applications, 80–95% of execution time is spent in a tiny fraction of the code. Optimize the wrong 95% of your codebase and users notice nothing. Optimize the right 5% and response times can drop by an order of magnitude. A profiler is simply the tool that tells you which 5% to look at.

The Golden Rule: Measure First, Optimize Second

Before touching any tool, internalize the workflow that separates effective performance work from cargo-cult optimization:

  • Reproduce the slowness with a realistic scenario (real data sizes, Release build, no debugger attached).
  • Measure with a profiler to find the actual hotspot.
  • Fix only the top bottleneck.
  • Measure again to confirm the fix worked — and didn't move the bottleneck somewhere worse.

Two pitfalls kill more profiling sessions than anything else. First, profiling Debug builds: the JIT disables inlining and other optimizations in Debug mode, so your hotspots may be pure fiction. Always profile a Release build (dotnet build -c Release). Second, profiling unrealistic data: an algorithm that's fine on 100 rows can be catastrophic on 100,000. Profile with production-shaped data.

A Slow Program to Profile

Let's use a deliberately flawed but realistic example — the kind of code that passes code review and works fine in testing, then falls over in production. It processes orders and builds a summary report:

using System.Diagnostics;

public record Order(int Id, int CustomerId, decimal Amount, DateTime Date);
public record Customer(int Id, string Name);

public class ReportBuilder
{
    // Pitfall 1: O(n*m) lookup — a linear scan inside a loop
    public string BuildReport(List<Order> orders, List<Customer> customers)
    {
        // Pitfall 2: string concatenation in a loop allocates a new
        // string every iteration — O(n²) memory copying
        string report = "";

        foreach (var order in orders)
        {
            var customer = customers.FirstOrDefault(c => c.Id == order.CustomerId);
            report += $"{order.Id},{customer?.Name},{order.Amount:F2}\n";
        }
        return report;
    }
}

public static class Program
{
    public static void Main()
    {
        var customers = Enumerable.Range(1, 5_000)
            .Select(i => new Customer(i, $"Customer {i}"))
            .ToList();

        var rng = new Random(42);
        var orders = Enumerable.Range(1, 200_000)
            .Select(i => new Order(i, rng.Next(1, 5_001),
                (decimal)rng.NextDouble() * 500, DateTime.UtcNow))
            .ToList();

        var sw = Stopwatch.StartNew();
        var report = new ReportBuilder().BuildReport(orders, customers);
        sw.Stop();

        Console.WriteLine($"Report length: {report.Length:N0} chars");
        Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds:N0} ms");
    }
}

On a typical machine this takes tens of seconds. Nothing in the code looks obviously wrong — FirstOrDefault and += are idiomatic C#. That's precisely why you profile instead of eyeballing.

.NET Performance Profiling with dotTrace

dotTrace is JetBrains' commercial profiler, available standalone or inside Rider and ReSharper. Its strength is approachability: excellent UI, one-click attach, and tight IDE integration that jumps from a hotspot straight to the source line.

Choosing a Profiling Mode

  • Sampling — periodically captures stack snapshots. Near-zero overhead, ideal first pass for CPU-bound problems.
  • Tracing — instruments every method entry/exit. Exact call counts, but heavy overhead. Use when you need to know how many times something ran.
  • Timeline — records events over time (CPU, GC, lock contention, I/O). The best default: it answers both "what is slow" and "when and why."

Reading the Results

Run the app under Timeline profiling and open the snapshot. Two views do most of the work:

  • Hot Spots lists methods by own time (time spent in the method itself, excluding callees). For our example, you'll see String.Concat and Enumerable.FirstOrDefault dominating.
  • Call Tree shows total time flowing down from Main, so you can see that BuildReport owns ~99% of the run, split between the LINQ scan and string concatenation.

The Timeline view adds a crucial detail: the GC track lights up with constant garbage collections. That's the string concatenation generating gigabytes of short-lived allocations. This is the "why" that raw timings can't give you — the program isn't just doing too much work; it's drowning the garbage collector.

PerfView Tutorial: Free, Powerful, Production-Ready

PerfView is Microsoft's free profiler built on Event Tracing for Windows (ETW). Its UI is famously spartan, but it does things dotTrace can't easily match: extremely low overhead (safe to run against production Windows servers), machine-wide profiling, and deep GC and JIT diagnostics. It's the tool the .NET runtime team itself uses.

Collecting a Trace

Download PerfView from the official GitHub releases, then collect a trace from an elevated prompt:

// Run from the command line (not C#) — shown here for reference:
// PerfView.exe run MyApp.exe          — profile one process start-to-finish
// PerfView.exe collect /MaxCollectSec:30   — machine-wide, 30 seconds
// PerfView.exe /GCCollectOnly collect      — GC-focused, near-zero overhead,
//                                            safe for long production runs

The Three Views That Matter

  • CPU Stacks — open the trace, choose "CPU Stacks," filter to your process. The "By Name" tab sorts methods by exclusive CPU; "Flame Graph" gives the visual overview. Our example shows a wide flame over System.String.Concat and the LINQ predicate.
  • GC Heap Alloc Ignore Free — shows which call stacks allocated the most memory. Here you'll see hundreds of thousands of String allocations traced straight back to BuildReport.
  • GCStats — a report of every collection: how many Gen 0/1/2 GCs occurred, total pause time, and "% time in GC." Above roughly 10%, allocation pressure is itself a bottleneck.

A practical tip: PerfView's stack views live and die by symbols. If you see raw addresses instead of method names, press the "Lookup Symbols" option and make sure your app ships PDB files even in Release builds (<DebugType>portable</DebugType> — the default in modern SDK projects).

Fixing the Bottlenecks the Profiler Found

Both profilers pointed to the same two culprits, so the fix is targeted rather than speculative: replace the O(n×m) linear search with a dictionary, and replace string concatenation with StringBuilder:

using System.Text;

public class FastReportBuilder
{
    public string BuildReport(List<Order> orders, List<Customer> customers)
    {
        // Fix 1: O(1) lookups instead of scanning the list per order
        var customersById = customers.ToDictionary(c => c.Id);

        // Fix 2: one growing buffer instead of a new string per iteration.
        // Pre-sizing avoids repeated internal buffer resizes.
        var sb = new StringBuilder(orders.Count * 32);

        foreach (var order in orders)
        {
            customersById.TryGetValue(order.CustomerId, out var customer);
            sb.Append(order.Id).Append(',')
              .Append(customer?.Name).Append(',')
              .Append(order.Amount.ToString("F2")).Append('\n');
        }
        return sb.ToString();
    }
}

On the same 200,000-order dataset, this version runs in well under a second — commonly a 50–100x improvement — and the GC track in dotTrace's Timeline goes almost flat. Notice what we didn't do: no caching layers, no parallelism, no unsafe code. Profiling told us exactly which two lines mattered, and two mechanical changes solved it.

Verify with a Benchmark

For before/after comparisons of isolated code, pair your profiler with BenchmarkDotNet — the profiler finds the hotspot, the benchmark proves the fix:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser] // reports allocations per operation — watch this column
public class ReportBenchmarks
{
    private List<Order> _orders = null!;
    private List<Customer> _customers = null!;

    [GlobalSetup]
    public void Setup()
    {
        _customers = Enumerable.Range(1, 5_000)
            .Select(i => new Customer(i, $"Customer {i}")).ToList();
        var rng = new Random(42);
        _orders = Enumerable.Range(1, 20_000)
            .Select(i => new Order(i, rng.Next(1, 5_001),
                (decimal)rng.NextDouble() * 500, DateTime.UtcNow)).ToList();
    }

    [Benchmark(Baseline = true)]
    public string Slow() => new ReportBuilder().BuildReport(_orders, _customers);

    [Benchmark]
    public string Fast() => new FastReportBuilder().BuildReport(_orders, _customers);
}

public static class Program
{
    public static void Main() => BenchmarkRunner.Run<ReportBenchmarks>();
}

dotTrace vs PerfView: Which Should You Use?

  • Use dotTrace when you're iterating locally, want fast answers with a friendly UI, need async call stitching (it reconstructs logical async flows across threads — a huge help for async/await-heavy code), or already live in Rider.
  • Use PerfView when you need to profile a production Windows server with minimal overhead, diagnose GC behavior in depth, investigate machine-wide issues (another process stealing CPU), or you need a free tool the whole team can run.
  • Cross-platform note: on Linux containers, the same ETW-style data is available via dotnet-trace and dotnet-counters, and PerfView can open the resulting .nettrace files. dotTrace also supports remote and cross-platform profiling in recent versions.

Best Practices and Common Pitfalls in C# Performance Optimization

  • Profile Release builds without a debugger. Debug-mode JIT output makes hotspot data misleading.
  • Fix one bottleneck at a time, re-measuring after each. Removing the top hotspot frequently reveals a different second one than the original profile suggested.
  • Watch allocations, not just CPU. In server workloads, GC pressure is often the real killer — high allocation rates cause pauses and cache misses that show up as mysteriously slow "everything."
  • Don't confuse wall-clock time with CPU time. A web request that takes 2 seconds but uses 5 ms of CPU is blocked — on the database, a lock, or a sync-over-async call. Timeline/thread views (or PerfView's Thread Time stacks) reveal waiting; CPU views don't.
  • Beware micro-benchmarking traps. Stopwatch around cold code measures JIT and cache warmup. Use BenchmarkDotNet, which handles warmup and statistics correctly.
  • Keep known-fast operations honest with tests or CI benchmarks so regressions are caught when they're introduced, not when customers complain.

Conclusion: Make .NET Performance Profiling a Habit

The core lesson of .NET performance profiling is that intuition about performance is unreliable, but measurement is cheap. Our example looked like ordinary idiomatic C#, yet contained two bottlenecks that made it 50–100x slower than necessary — and both dotTrace and PerfView identified them within minutes.

Key takeaways:

  • Always measure before optimizing; the hotspot is rarely where you think it is.
  • Profile Release builds with realistic data volumes.
  • dotTrace excels at local, iterative profiling with a polished UI and async support; PerfView excels at low-overhead production diagnostics, GC analysis, and it's free.
  • The most common C# bottlenecks are mundane: linear searches inside loops, string concatenation, excessive allocations, and sync-over-async blocking.
  • Confirm every fix with a second profile or a BenchmarkDotNet run — including the [MemoryDiagnoser] allocation column.

Next time an app feels slow, resist the urge to guess. Attach a profiler, read the flame graph, and fix the code that's actually costing you. Your users — and your future self reading the diff — will thank you.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...