Appreciating C#'s Ongoing Journey
Real performance wins hiding in three years of C# language evolution

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
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
}
}
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;
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.
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.
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
Feature | When to Use | Performance Impact | Migration Effort |
---|---|---|---|
params ReadOnlySpan<T> C# 13 | Hot-path methods called frequently with small arrays | High - Eliminates allocations | Low - Change method signature |
System.Threading.Lock C# 13 | Contended locks in concurrent code | Medium - Better under load | Low - Change field declaration |
Collection Expressions C# 12 | Temporary collections for processing | Medium - With spans | Medium - Update initializers |
field Keyword C# 14 | Properties with simple validation | None - Code quality win | Low - Remove backing fields |
Extension Members C# 14 | API design for domain-specific extensions | None - Readability win | High - Rethink API design |
Null-Conditional Assignment C# 14 | Data manipulation with nullable chains | None - Reduces branches | Medium - 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.
Share this post
Twitter
Facebook
Reddit
LinkedIn
Pinterest
Email