Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 4 of 19 (56 editions)
For two decades, the pattern has been the same: declare a private readonly object _lock = new(), wrap your critical section in lock(_lock), and hope nobody accidentally locks on this, a string literal, or some other shared reference. It works, but it’s a pattern held together by convention, not by the type system.
.NET 9 introduces System.Threading.Lock, a purpose-built type that the compiler recognizes and optimizes. No more locking on arbitrary objects.
The Old Way
public class ConnectionPool{ private readonly object _lock = new(); private readonly List<Connection> _connections = [];
public Connection Acquire() { lock (_lock) { var conn = _connections.FirstOrDefault(c => !c.InUse); if (conn is not null) conn.InUse = true; return conn ?? CreateNew(); } }}The compiler turns that lock into Monitor.Enter / Monitor.Exit. It works, but there’s no type safety — you could accidentally pass _lock to something that locks on it elsewhere, or some junior dev writes lock(this) in a code review at 5 PM.
The New Way
using System.Threading;
public class ConnectionPool{ private readonly Lock _lock = new(); private readonly List<Connection> _connections = [];
public Connection Acquire() { lock (_lock) { var conn = _connections.FirstOrDefault(c => !c.InUse); if (conn is not null) conn.InUse = true; return conn ?? CreateNew(); } }}Looks almost identical, right? The difference is under the hood. When the compiler sees lock on a System.Threading.Lock, it emits a call to Lock.EnterScope() which returns a ref struct that releases the lock on Dispose. This is more efficient than Monitor.Enter/Exit because the JIT can optimize the scope-based pattern better.
Scoped Locking
You can also use the scope explicitly when you need more control:
public void TransferAll(ConnectionPool other){ using (_lock.EnterScope()) { foreach (var conn in _connections) { other.Add(conn); } _connections.Clear(); }}The Lock.Scope is a ref struct, so it can’t escape the method or get boxed. The lock is guaranteed to release when the scope ends, even if an exception fires.
Why Bother?
- Type safety. You can’t accidentally lock on a string, a type, or
this. The type system prevents misuse. - Performance.
EnterScope()avoids the overhead ofMonitor’s thread-affinity tracking in the uncontended case. - Intent. Seeing
Lockin a field declaration screams “synchronization primitive.” Seeingobjectscreams nothing.
Key Takeaway
Replace private readonly object _lock = new() with private readonly Lock _lock = new(). Same lock keyword, same syntax, better codegen, and the type system finally prevents the classic locking mistakes. One find-and-replace, zero behavior changes.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
MapStaticAssets: Static Files That Actually Cache Properly
UseStaticFiles() has been the default for serving CSS, JS, and images since ASP.NET Core 1.0. It works. But it’s dumb: no content-based ETags, no precompressed variants, no fingerprinted cache headers. You end up with stale CSS in your users’ browsers, or you manually configure response caching middleware, or you append ?v=2 query strings like it’s 2010.
.NET 9’s MapStaticAssets() fixes all of this with one line.
The Swap
// Before: basic file serving, no smart cachingapp.UseStaticFiles();
// After: content-hashed ETags, precompressed files, immutable cache headersapp.MapStaticAssets();What You Get for Free
Content-based ETags. The ETag is a hash of the file content, not the last-modified timestamp. Deploy a new version with an identical file? No unnecessary download. Change one byte? Cache invalidates automatically.
Precompressed responses. If you have app.css.br (Brotli) or app.css.gz (gzip) alongside app.css, MapStaticAssets serves the compressed variant automatically based on Accept-Encoding. Blazor and the .NET build system generate these for you.
Immutable cache headers. For fingerprinted files (like Blazor’s app.abc123.css), it sets Cache-Control: max-age=31536000, immutable. The browser caches it forever and never asks again. When the content changes, the filename changes, and the old cache entry is simply abandoned.
Blazor Integration
In a Blazor app, your App.razor references static assets, and the build system fingerprints them:
<link rel="stylesheet" href="@Assets["app.css"]" /><script src="@Assets["app.js"]"></script>@Assets["app.css"] resolves to something like app.a1b2c3d4.css at build time. Combined with MapStaticAssets(), the browser gets an immutable, precompressed file with a year-long cache lifetime. Deploy a new version and the filename changes — instant cache bust, zero stale content.
Razor Pages / MVC
Works the same way with tag helpers:
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />With MapStaticAssets, the version append uses the same content hash, and responses include proper cache headers.
Response Headers Comparison
| Header | UseStaticFiles() | MapStaticAssets() |
|---|---|---|
| ETag | File modified time | Content hash |
| Cache-Control | Not set | max-age=31536000, immutable (fingerprinted) |
| Content-Encoding | Manual middleware | Automatic (br/gz) |
| Vary | Not set | Accept-Encoding |
Key Takeaway
Replace app.UseStaticFiles() with app.MapStaticAssets() and get content-hashed ETags, automatic Brotli/gzip serving, and immutable cache headers, all without configuration. Your CSS will never be stale again, and your users download fewer bytes. One line, done.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
CompositeFormat: Stop Re-Parsing Format Strings on Every Call
string.Format("{0} processed {1} items in {2:F2}ms", name, count, elapsed) looks innocent enough. But every time you call it, the runtime parses that format string from scratch — finding the {0}, {1:F2} placeholders, validating them, and building the output. Call it once? Fine. Call it 100,000 times in a logging or reporting hot path? That parsing adds up.
CompositeFormat lets you parse the format string once and reuse the compiled representation forever.
The Before
// In a high-throughput logger or report generatorfor (int i = 0; i < records.Length; i++){ // Parses the format string on EVERY iteration var line = string.Format( "[{0:yyyy-MM-dd HH:mm:ss}] {1}: processed {2:N0} records ({3:P1} complete)", records[i].Timestamp, records[i].Name, records[i].Count, records[i].Progress);
output.AppendLine(line);}The After
using System.Text;
// Parse once at startupprivate static readonly CompositeFormat LogFormat = CompositeFormat.Parse( "[{0:yyyy-MM-dd HH:mm:ss}] {1}: processed {2:N0} records ({3:P1} complete)");
// Use the pre-parsed format in the hot pathfor (int i = 0; i < records.Length; i++){ var line = string.Format( null, // IFormatProvider (null = current culture) LogFormat, // pre-parsed, no re-parsing records[i].Timestamp, records[i].Name, records[i].Count, records[i].Progress);
output.AppendLine(line);}The CompositeFormat.Parse() call does the format string analysis once. Every subsequent string.Format call with that CompositeFormat skips the parsing step entirely and goes straight to writing output.
Works with StringBuilder Too
private static readonly CompositeFormat RowFormat = CompositeFormat.Parse("| {0,-20} | {1,10:C2} | {2,8:N0} |");
var sb = new StringBuilder();sb.AppendLine("| Product | Price | Quantity |");sb.AppendLine("|----------------------|------------|----------|");
foreach (var item in inventory){ sb.AppendFormat(null, RowFormat, item.Name, item.Price, item.Quantity); sb.AppendLine();}When to Use It
- Logging pipelines where the same format pattern is applied to millions of entries
- Report generators that format table rows, CSV lines, or export records
- Template engines that apply the same pattern repeatedly with different data
- Any loop where
string.Formatwith a constant format string shows up in your profiler
When NOT to Use It
If your format string is used once or rarely, the overhead of pre-parsing isn’t worth it. CompositeFormat shines specifically in high-repetition scenarios.
Key Takeaway
CompositeFormat.Parse() compiles your format string once. Every subsequent string.Format(provider, compositeFormat, args) call skips re-parsing and goes straight to output. It’s a one-line change that eliminates redundant string parsing on hot paths. If your profiler highlights string.Format, this is your fix.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.