Skip to main content

.NET Performance Tips

Introduction

Performance optimization is a critical aspect of software development that can significantly impact user experience and operational costs. In .NET applications, understanding how to write efficient code can lead to faster execution times, reduced memory consumption, and better resource utilization.

This guide introduces essential performance tips for .NET developers, ranging from basic optimizations to more advanced techniques. Whether you're building a simple console application or a large-scale enterprise system, these practices will help you create more efficient and responsive applications.

Why Performance Matters

Before diving into specific tips, it's important to understand why performance optimization matters:

  • User Experience: Faster applications provide better user experiences
  • Cost Efficiency: Optimized applications require less infrastructure
  • Scalability: Well-optimized code scales better under heavy loads
  • Battery Life: For mobile applications, performance directly impacts battery consumption

Memory Management Tips

1. Understand Value Types vs Reference Types

In .NET, how objects are allocated and managed in memory significantly impacts performance.

csharp
// Value types are stored on the stack
int number = 42; // Stored directly on the stack
DateTime date = DateTime.Now; // Also a value type

// Reference types are stored on the heap (with references on the stack)
string text = "Hello"; // Reference type
List<int> numbers = new List<int>(); // Reference type

Best Practice: Prefer value types for small, simple data structures that don't change frequently.

2. Use Object Pooling for Frequently Created Objects

Creating and garbage collecting objects repeatedly can be expensive. Object pooling helps mitigate this cost.

csharp
// Using Microsoft.Extensions.ObjectPool
using Microsoft.Extensions.ObjectPool;

// Create a pool for StringBuilder objects
ObjectPool<StringBuilder> stringBuilderPool = new DefaultObjectPool<StringBuilder>(
new StringBuilderPooledObjectPolicy());

// Get an object from the pool
StringBuilder sb = stringBuilderPool.Get();
try
{
// Use the StringBuilder
sb.Append("Hello World");
// Process the result
Console.WriteLine(sb.ToString());
}
finally
{
// Important: Return the object to the pool when done
sb.Clear(); // Reset the object state
stringBuilderPool.Return(sb);
}

Best Practice: Use object pooling for frequently created and short-lived objects, especially in high-throughput scenarios.

3. Dispose IDisposable Resources Properly

Proper resource disposal is crucial for performance and preventing memory leaks.

csharp
// Recommended approach: using statement
using (SqlConnection connection = new SqlConnection(connectionString))
{
connection.Open();
// Use the connection
} // Automatically disposes when exiting the block

// Alternative approach for .NET Core 8.0+
using FileStream fs = new FileStream("file.txt", FileMode.Open);
// Use the file stream
// It will be disposed when the containing method exits

Best Practice: Always use using statements or blocks for IDisposable resources.

String Manipulation Optimizations

1. Use StringBuilder for Multiple String Concatenations

String concatenation creates new string instances each time, which can be inefficient.

csharp
// Inefficient approach - creates multiple intermediate strings
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i.ToString() + ", ";
}

// Efficient approach using StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i).Append(", ");
}
string efficientResult = sb.ToString();

// Output comparison (both produce the same output)
// "0, 1, 2, 3, ..., 9999, "

Performance Impact: For large numbers of concatenations, StringBuilder can be hundreds of times faster.

2. Use String Interpolation Wisely

String interpolation is convenient but can be inefficient when overused.

csharp
// Less efficient with many variables
string name = "John";
int age = 30;
string city = "New York";
string country = "USA";

// Each + operation creates a new string
string inefficient = "Name: " + name + ", Age: " + age + ", Lives in " + city + ", " + country;

// More efficient and readable
string efficient = $"Name: {name}, Age: {age}, Lives in {city}, {country}";

// For complex formatting, consider StringBuilder
StringBuilder sb = new StringBuilder();
sb.Append("Name: ").Append(name)
.Append(", Age: ").Append(age)
.Append(", Lives in ").Append(city)
.Append(", ").Append(country);
string result = sb.ToString();

Best Practice: Use string interpolation for simple cases and StringBuilder for complex or repeated concatenations.

LINQ and Collection Performance

1. Be Mindful of LINQ Query Execution

LINQ provides elegant syntax but can introduce performance overhead if used carelessly.

csharp
// Sample collection
List<int> numbers = Enumerable.Range(1, 1000000).ToList();

// Less efficient: Multiple iterations over the collection
var inefficientQuery = numbers.Where(n => n % 2 == 0)
.Select(n => n * 2)
.ToList(); // Materializes the query

// More efficient: Chain operations to reduce iterations
var efficientQuery = numbers.Select(n => n) // Select is executed only once
.Where(n => n % 2 == 0)
.Select(n => n * 2)
.ToList();

// Most efficient: Avoid intermediary allocations with a single pass
var mostEfficient = new List<int>();
foreach (var n in numbers)
{
if (n % 2 == 0)
{
mostEfficient.Add(n * 2);
}
}

Best Practice: Be aware of when LINQ queries execute and try to minimize multiple iterations over large collections.

2. Choose the Right Collection Type

Using the appropriate collection type for your scenario can significantly impact performance.

csharp
// Scenario: Frequently checking if an item exists in a collection

// Less efficient for lookups (O(n) operation)
List<string> namesList = new List<string> { "Alice", "Bob", "Charlie", "David" };
bool containsAlice = namesList.Contains("Alice"); // List performs linear search

// More efficient for lookups (O(1) operation)
HashSet<string> namesSet = new HashSet<string> { "Alice", "Bob", "Charlie", "David" };
bool containsAliceInSet = namesSet.Contains("Alice"); // HashSet uses hash-based lookup

// Scenario: Key-value pairs
Dictionary<int, string> userMap = new Dictionary<int, string>
{
{ 1, "Alice" },
{ 2, "Bob" }
};
string userName = userMap[1]; // Fast O(1) lookup

Best Practice: Choose collections based on your access patterns:

  • List<T>: Good for sequential access and when order matters
  • HashSet<T>: Excellent for unique collections and fast lookups
  • Dictionary<TKey, TValue>: Ideal for key-value lookups

Asynchronous Programming

1. Use Async/Await Correctly

Asynchronous programming can significantly improve application responsiveness but comes with its own performance considerations.

csharp
// Less efficient: Blocking the thread
public string GetDataSync()
{
var client = new HttpClient();
// This blocks the current thread while waiting
string result = client.GetStringAsync("https://api.example.com/data").Result;
return result;
}

// More efficient: Proper async implementation
public async Task<string> GetDataAsync()
{
using var client = new HttpClient();
// This frees the thread while waiting
string result = await client.GetStringAsync("https://api.example.com/data");
return result;
}

// Usage in an async context
public async Task ProcessDataAsync()
{
string data = await GetDataAsync();
Console.WriteLine($"Received {data.Length} bytes");
}

Best Practice: Use async/await for I/O-bound operations to free up threads while waiting for results.

2. Avoid Async Void

Using async void can lead to unhandled exceptions and difficulties in error handling.

csharp
// Bad practice: async void
async void ProcessDataVoid()
{
await Task.Delay(1000);
// If an exception occurs here, it cannot be caught by the caller
throw new Exception("Something went wrong");
}

// Good practice: async Task
async Task ProcessDataTask()
{
await Task.Delay(1000);
// Exceptions can be properly handled
throw new Exception("Something went wrong");
}

// Usage with proper exception handling
async Task SafeCallAsync()
{
try
{
await ProcessDataTask();
}
catch (Exception ex)
{
Console.WriteLine($"Handled exception: {ex.Message}");
}
}

Best Practice: Always return Task or Task<T> from async methods unless you're writing an event handler.

Advanced Performance Tips

1. Use Span<T> for Efficient Memory Operations

Span<T> provides a safe way to manipulate memory without allocations, which is particularly useful for working with arrays and strings.

csharp
// Traditional string parsing (creates substrings = allocations)
string text = "Hello, World! Welcome to .NET";
string firstWord = text.Substring(0, 5); // Allocates new string "Hello"

// Using Span<T> (no allocations)
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> firstWordSpan = span.Slice(0, 5); // No allocation

// Parsing numbers without allocations
string numberText = "12345";
ReadOnlySpan<char> numberSpan = numberText.AsSpan();
if (int.TryParse(numberSpan, out int result))
{
Console.WriteLine($"Parsed: {result}");
}

Best Practice: Use Span<T> and ReadOnlySpan<T> when working with memory regions to avoid unnecessary allocations.

2. Optimize Startup Performance

For applications where startup time matters, consider lazy loading features and trimming unnecessary dependencies.

csharp
// Example of lazy initialization
public class ExpensiveService
{
private Lazy<DatabaseConnection> _connection;

public ExpensiveService()
{
// Connection is only created when actually needed
_connection = new Lazy<DatabaseConnection>(() =>
{
Console.WriteLine("Creating expensive database connection");
return new DatabaseConnection();
});
}

public void ProcessData()
{
// Connection is initialized only when this is called
_connection.Value.ExecuteQuery("SELECT * FROM Users");
}
}

public class DatabaseConnection
{
public void ExecuteQuery(string query)
{
Console.WriteLine($"Executing: {query}");
}
}

// Usage
var service = new ExpensiveService(); // No expensive connection yet
// ... later when needed
service.ProcessData(); // Now the connection is created

Best Practice: Initialize expensive resources only when needed and consider using Lazy<T> for deferred initialization.

Real-World Performance Optimization Example

Let's look at a real-world scenario of optimizing an API endpoint that processes a large amount of data:

csharp
// Original implementation - inefficient
public async Task<IActionResult> GetProductsAsync()
{
// Inefficient: Loads all products into memory
var products = await _dbContext.Products
.Include(p => p.Category)
.Include(p => p.Tags)
.ToListAsync();

// Inefficient: Creates new objects for each product
var results = products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Category = p.Category.Name,
Tags = string.Join(", ", p.Tags.Select(t => t.Name))
}).ToList();

return Ok(results);
}

// Optimized implementation
public async Task<IActionResult> GetProductsAsync([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
// Efficient: Uses pagination
var query = _dbContext.Products
.AsNoTracking() // Improves EF Core performance when read-only
.Include(p => p.Category)
.Skip((page - 1) * pageSize)
.Take(pageSize);

// Efficient: Projects only needed fields
var results = await query.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Category = p.Category.Name,
Tags = string.Join(", ", p.Tags.Select(t => t.Name))
}).ToListAsync();

// Include pagination metadata
var totalCount = await _dbContext.Products.CountAsync();
var metadata = new
{
TotalCount = totalCount,
PageSize = pageSize,
CurrentPage = page,
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize)
};

Response.Headers.Add("X-Pagination", JsonSerializer.Serialize(metadata));

return Ok(results);
}

This optimization includes several performance improvements:

  • Added pagination to limit data retrieval
  • Used AsNoTracking() to improve Entity Framework performance for read-only operations
  • Projected only the necessary fields in the query
  • Added pagination metadata to help clients navigate large result sets

Measuring Performance

Always measure before and after optimization to ensure your changes are actually improving performance:

csharp
using System.Diagnostics;

// Example of measuring execution time
public void MeasurePerformance()
{
Stopwatch stopwatch = Stopwatch.StartNew();

// Code to measure
for (int i = 0; i < 1000000; i++)
{
// Operation being tested
Math.Sqrt(i);
}

stopwatch.Stop();
Console.WriteLine($"Execution time: {stopwatch.ElapsedMilliseconds} ms");
}

// For memory usage, you can use:
public void ReportMemoryUsage()
{
// Force garbage collection to get accurate readings
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

long memoryUsed = GC.GetTotalMemory(true) / 1024;
Console.WriteLine($"Memory usage: {memoryUsed} KB");
}

Summary

Optimizing .NET applications requires attention to several key areas:

  1. Memory Management: Understand value vs. reference types, use object pooling, and properly dispose of resources
  2. String Handling: Use StringBuilder for concatenation and be careful with string operations
  3. Collections and LINQ: Choose the right collection for your scenario and use LINQ efficiently
  4. Async Programming: Implement asynchronous patterns correctly to maintain responsiveness
  5. Advanced Techniques: Use features like Span<T> for memory-efficient operations
  6. Measurement: Always measure performance before and after changes

Remember that premature optimization is often counterproductive. Focus first on writing clean, maintainable code, and optimize only when necessary based on performance measurements.

Additional Resources

  1. Microsoft .NET Performance Documentation
  2. Writing High-Performance C# and .NET Code
  3. BenchmarkDotNet - A powerful .NET library for benchmarking
  4. PerfView - Performance analysis tool

Exercises

  1. Memory Optimization: Take a simple application that creates many strings and modify it to use StringBuilder. Measure the performance difference.

  2. Collection Performance: Create a program that performs lookup operations on a List vs. Dictionary with at least 10,000 items. Compare the performance.

  3. Async Pattern: Convert a synchronous file processing method to use the async pattern properly. Test it with large files.

  4. Span Practice: Write a method that parses comma-separated values from a string without creating substring allocations using Span<T>.

  5. Measure Real Application: Profile a real application you've written using tools like dotnet-trace or Visual Studio's performance profiler. Identify and fix the top three bottlenecks.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)