Writing clean, maintainable C# code is important, but it's equally crucial to ensure your code performs efficiently — especially in performance-critical applications. Optimization in C# involves understanding how the language, .NET runtime, and hardware work together, and making decisions that lead to more efficient code execution.

In this post, we'll look at a few key techniques for optimizing your C# code with detailed explanations and examples.

Example:

Imagine you have an API that fetches data from two different services:

public async Task<List<string>> GetDataAsync()
{
    var data1 = await FetchFromService1Async();
    var data2 = await FetchFromService2Async();
    
    return data1.Concat(data2).ToList();
}

This code runs both asynchronous calls sequentially, which is inefficient because the second service fetch doesn't start until the first completes. You can optimize it by running them in parallel:

public async Task<List<string>> GetDataAsync()
{
    var task1 = FetchFromService1Async();
    var task2 = FetchFromService2Async();
    
    await Task.WhenAll(task1, task2);

    return task1.Result.Concat(task2.Result).ToList();
}

Why this is better: Now, both service calls run concurrently, reducing the overall wait time and improving performance.

2. Minimize Memory Allocations

Frequent memory allocations, especially in tight loops or performance-critical paths, can slow down your application and increase garbage collection overhead.

Use StringBuilder for Concatenations

Strings in C# are immutable, so every time you concatenate strings using the + operator, new string instances are created, causing unnecessary allocations. For scenarios where you need to concatenate strings in a loop, use StringBuilder.

// Inefficient string concatenation in a loop
string result = "";
for (int i = 0; i < 1000; i++)
{
    result += i.ToString();
}

Optimized Code:

var builder = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    builder.Append(i.ToString());
}
string result = builder.ToString();

Why this is better: StringBuilder minimizes memory allocations by modifying the internal buffer instead of creating a new string with each operation. This leads to significant performance improvements, especially in loops or large-scale concatenations.

3. Leverage Caching

When dealing with expensive operations such as database calls or heavy computations, caching the results can drastically improve performance by avoiding redundant work.

Example:

Suppose you're fetching data from a database every time a user requests it, like this:

public UserData GetUser(int userId)
{
    return _dbContext.Users.FirstOrDefault(u => u.Id == userId);
}

This leads to a database hit on every request, which may not be necessary if the data doesn't change frequently.

Optimized Code with Caching:

private Dictionary<int, UserData> _userCache = new Dictionary<int, UserData>();

public UserData GetUser(int userId)
{
    if (_userCache.TryGetValue(userId, out var cachedUser))
    {
        return cachedUser;
    }

    var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
    if (user != null)
    {
        _userCache[userId] = user;
    }
    return user;
}

Why this is better: By caching the user data in memory, subsequent calls for the same user can be served from the cache rather than querying the database, reducing latency and load on the database.

4. Avoid Using LINQ in Performance-Critical Code Paths

While LINQ (Language Integrated Query) is convenient and readable, it comes with a slight performance cost due to method calls, lambda expressions, and deferred execution. In performance-critical scenarios, it may be beneficial to use traditional loops instead of LINQ.

Example:

// LINQ version
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();

Optimized Code:

// Loop version
var evenNumbers = new List<int>();
foreach (var number in numbers)
{
    if (number % 2 == 0)
    {
        evenNumbers.Add(number);
    }
}

Why this is better: While LINQ is highly optimized for most cases, traditional loops provide more control and reduce the overhead introduced by method calls and deferred execution in critical code paths.

5. Use Value Types Instead of Reference Types in Hot Paths

When dealing with performance-critical code, value types (such as int, struct, double) can be more efficient than reference types because they are allocated on the stack and don't incur the overhead of garbage collection.

Example:

Let's say you have a Point class:

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

If you use this class in a loop where performance is crucial, it will generate a lot of allocations on the heap. Instead, you can use a struct:

public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

Why this is better: struct is a value type and will be allocated on the stack, reducing heap allocations and making garbage collection less frequent.

6. Profile and Benchmark Your Code

Before applying optimizations, you should always measure the current performance to identify bottlenecks. Tools like BenchmarkDotNet or dotTrace can help you profile and analyze your code's execution time, memory usage, and more.

Example using BenchmarkDotNet:

[MemoryDiagnoser]
public class StringConcatenationBenchmark
{
    [Benchmark]
    public string StringPlusOperator()
    {
        string result = "";
        for (int i = 0; i < 1000; i++)
        {
            result += i.ToString();
        }
        return result;
    }

    [Benchmark]
    public string StringBuilderMethod()
    {
        var builder = new StringBuilder();
        for (int i = 0; i < 1000; i++)
        {
            builder.Append(i.ToString());
        }
        return builder.ToString();
    }
}

Why this is better: Benchmarking helps you understand where the real bottlenecks are in your application. Often, premature optimizations are unnecessary, and identifying the true performance problems through measurement is more effective.

7. Parallelize CPU-bound Work

If your application is performing CPU-bound tasks (e.g., image processing, data transformation), you can make use of multiple cores by parallelizing the work.

Example:

Let's say you're processing a large array of data:

foreach (var item in data)
{
    Process(item);
}

Optimized Code with Parallelization:

Parallel.ForEach(data, item =>
{
    Process(item);
});

Why this is better: Parallel.ForEach can distribute the work across multiple threads, making use of the available CPU cores to process the data faster.

Conclusion

Optimizing C# code isn't just about making things run faster — it's about writing efficient, maintainable, and scalable code. The optimizations mentioned in this article, like using asynchronous programming effectively, minimizing memory allocations, leveraging caching, and parallelizing CPU-bound tasks, can lead to significant performance gains in real-world applications.

However, it's important to remember the golden rule of optimization: "Measure before you optimize." Tools like profilers and benchmarking libraries can help you identify where optimizations are truly needed.

By following these practices, you'll ensure that your C# code performs optimally, providing a better user experience and making the most of your application's resources.