Appreciating C#'s Ongoing Journey

Real performance wins hiding in three years of C# language evolution

8 minute read

My weekend project found me staring at an older C# 11 and .NET 7 web application I’d built for one of my startups. Due to an integration partnership and SDK update, I had to get it migrated to .NET 10. After spending the past few years deep in C++ building game backend systems, I wondered what I’d missed in C# (our web front-ends “just worked” since I’d built them, so out of sight, out of mind).

The basic upgrade took less than a day. I spent the rest of the weekend reading about, evaluating, and using the new language patterns. Hidden within the syntactical sugar and keystroke savings were some real performance improvements.

ReadOnlySpan Eliminates Hidden Allocations

The biggest performance win: ReadOnlySpan<T>. It represents a contiguous region of memory without allocations. It’s like a lightweight view over arrays or strings that lives on the stack instead of the heap that they’ve kept improving the past few versions.

This matters when you’re processing thousands of game events per second:

C# 11 hidden allocation:

static int ProcessGameScores(params int[] scores)
{
    var total = 0;
    for (int i = 0; i < scores.Length; i++) total += scores[i];
    return total;
}

// Every call creates a heap array
var result = ProcessGameScores(95, 87, 92, 88, 91);

C# 13 zero-allocation approach:

static int ProcessGameScores(params ReadOnlySpan<int> scores)
{
    var total = 0;
    for (int i = 0; i < scores.Length; i++) total += scores[i];
    return total;
}

// Same call, stays on the stack
var result = ProcessGameScores([95, 87, 92, 88, 91]);

Change one word in the method signature. The performance impact adds up fast. My event processing pipeline saw nearly 20% fewer Gen0 garbage collections after eliminating these micro-allocations.

Performance Impact by C# Version

C# 12
Collection expressions + spans reduce copying
C# 13
params ReadOnlySpan eliminates array allocations
C# 14
First-class span flows reduce adapter code

Extension Properties Beyond Methods

C# 14’s extension members solve a design problem I’ve repeatedly hit by allowing extensions with properties and indexers, not just methods.

Our business logic layer has several extensions depending on who/what/where/why the data is being rendered. Traditional extension methods feel clunky:

C# 11 verbose extensions:

public static class PlayerStatsExtensions
{
    public static bool HasValidStreak(this List<int> scores) => 
        scores.Count >= 3 && scores.TakeLast(3).All(s => s > 85);
    
    public static int GetBestScoreInWindow(this List<int> scores, int windowSize) => 
        scores.TakeLast(windowSize).Max();
}

// Usage feels like function calls
if (playerScores.HasValidStreak()) { /* award bonus */ }
var bestRecent = playerScores.GetBestScoreInWindow(5);

C# 14 natural property syntax:

extension(List<int> scores)
{
    public bool HasValidStreak => 
        scores.Count >= 3 && scores.TakeLast(3).All(s => s > 85);
    
    public int this[Range window] => 
        scores[window].Max();
}

// Clean, property-like access
if (playerScores.HasValidStreak) { /* award bonus */ }
var bestRecent = playerScores[^5..];  // Last 5 scores

This creates APIs that feel natural. When processing game telemetry where readability matters for rapid debugging, these syntax improvements help.

The Field Keyword Prevents Property Bugs

The field keyword (C# 14) eliminates a category of property bugs. It lets you add custom logic to automatically implemented properties without manually declaring a separate private field.

C# 11 manual backing field:

class GameSession
{
    private int _scoreMultiplier = 1;
    public int ScoreMultiplier
    {
        get => _scoreMultiplier;
        set => _scoreMultiplier = value > 0 ? value : 1;  // Easy to mistype field name
    }
}

C# 14 compiler-managed field:

class GameSession
{
    public int ScoreMultiplier
    {
        get => field;
        set => field = value > 0 ? value : 1;  // Compiler ensures correctness
    }
}
 
Debugging tip: The compiler-generated backing fields don’t show up in the debugger. But you can still debug the property value itself. The field keyword prevents the backing field from leaking into your class scope where other methods might bypass your validation. If you need to inspect the backing field value during debugging, use the property name in your watch window, not the field. The property accessor will show you the actual stored value.

Null-Conditional Assignment Works Finally

This change makes defensive coding cleaner. Null-conditional assignment (C# 14) lets you assign through null-conditional access:

C# 11 defensive approach:

class GameMatch { public PlayerStats? Winner { get; set; } }
class PlayerStats { public int Score { get; set; } }

// Manual null checking
if (match?.Winner != null) 
    match.Winner.Score += bonusPoints;

C# 14 direct assignment:

// Just works
match?.Winner?.Score += bonusPoints;
 
Debugging tip: When everything’s nullable, debugging gets tricky. I design null flows intentionally. In these game systems, I use nullable types for optional bonuses or power-ups, but keep core game state non-nullable. This makes debugging predictable.

Better Concurrency with System.Threading.Lock

C# 13 introduced a new Lock type that works with the existing lock statement but performs better under contention. Important for my analytics dashboard where multiple threads update shared counters.

Before:

static readonly object gate = new();
static int activePlayerCount = 0;

// Traditional Monitor-based locking
lock (gate) { activePlayerCount++; }

After (C# 13):

static readonly Lock gate = new();
static int activePlayerCount = 0;

// Same syntax, better performance under load
lock (gate) { activePlayerCount++; }

The new Lock type provides more efficient synchronization in many workloads compared to Monitor. Change one line, get better performance.

Collection Expressions Do More Than Look Pretty

Collection expressions (C# 12) flow into span-based code:

C# 11 allocation-heavy:

var scores = new List<int> { 95, 87, 92 };
var bonusScores = new[] { 5, 3 };
var combined = scores.Concat(bonusScores).ToList();  // Multiple allocations

C# 12 span-friendly:

var scores = [95, 87, 92];
var bonusScores = [5, 3];
Span<int> combined = [..scores, ..bonusScores];  // Stack allocation when possible

The syntax is cleaner. The real benefit is how easily it flows into allocation-free processing.

 
Performance note: Not every collection expression creates a span. The compiler chooses based on context. But when it can use stack allocation, the performance difference is significant in tight loops.

Real Migration Results

My web application processes real-time data from multiple online game databases. Speed matters—every millisecond counts when aggregating thousands of events per second. But I avoid premature optimization.

Here’s where I saw actual wins:

Converting hot-path params methods to ReadOnlySpan: I had data processing methods called hundreds of times per second. Changing params int[] to params ReadOnlySpan<int> in my event score aggregation pipeline eliminated 2MB of allocations per minute during peak load.

Replacing object-based locks with Lock in contended sections: My player statistics cache had lock contention during busy periods. Switching to System.Threading.Lock improved my 95th percentile response time by almost 15ms during peak concurrent load.

Using collection expressions in temporary data processing: When parsing game events from JSON, we frequently create temporary collections for validation. Collection expressions with spans keep more of this work on the stack, reducing GC pressure.

Cleaning up property implementations with field keyword: I had validation logic scattered across dozens of model classes. The field keyword let me consolidate validation into property setters without manual backing fields, preventing bugs where validation was bypassed.

Not every feature is immediately applicable, but when they fit your workload, the benefits compound quickly.

 
Cost Reality Check: Based on research from Cast AI and major cloud providers, CPU typically represents 88% of compute costs, with memory at 12%. When my allocation optimizations reduced CPU usage by eliminating GC overhead, that directly translated to lower cloud bills. A 20% reduction in Gen0 collections means 20% less CPU spent on garbage collection—which for our workload running on AWS EC2 instances, looks like we’re forecasting to save approximately $70/month in compute costs at our current burn rate (~17 days in our billing cycle as of writing this).

Looking Forward

These changes work together. A web application processing JSON, validating inputs, and manipulating collections can see real performance improvements by adopting these patterns systematically.

I’m also impressed that, out the gate, the migraiton to .NET 10 from 7 compiled without any real errors (outside of package updates). The C# team maintained compatibility while enabling better patterns.

For teams planning similar migrations, start with the performance-impacting changes: params ReadOnlySpan<T>, the new Lock type, and span-friendly collection expressions. The syntax improvements can follow gradually.

Coming back to C# after time in C++, the language keeps evolving toward “make the right thing easy” while maintaining the safety and productivity that drew me to .NET originally.

Migration Summary: What to Tackle First

Based on my real-world migration experience, here’s a practical guide for prioritizing these features:

C# 11 → 14 Migration Priorities

FeatureWhen to UsePerformance ImpactMigration Effort
params ReadOnlySpan<T>
C# 13
Hot-path methods called frequently with small arraysHigh - Eliminates allocationsLow - Change method signature
System.Threading.Lock
C# 13
Contended locks in concurrent codeMedium - Better under loadLow - Change field declaration
Collection Expressions
C# 12
Temporary collections for processingMedium - With spansMedium - Update initializers
field Keyword
C# 14
Properties with simple validationNone - Code quality winLow - Remove backing fields
Extension Members
C# 14
API design for domain-specific extensionsNone - Readability winHigh - Rethink API design
Null-Conditional Assignment
C# 14
Data manipulation with nullable chainsNone - Reduces branchesMedium - Update conditional logic

Start with the high-impact, low-effort wins: params ReadOnlySpan<T> and System.Threading.Lock first. The syntax improvements can follow as you touch related code during normal development.