
Analyzing Dotnet Performance
- 1 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
This is a copy of analyzing-dotnet-performance by dotnet - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
analyzing-dotnet-performance is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
- analyzing-dotnet-performance
- AI & Agent Building
- AI-coding skill
Analyzing Dotnet Performance by the numbers
- 1 all-time installs (skills.sh)
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill analyzing-dotnet-performanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
.NET Performance Patterns
Scan C#/.NET code for performance anti-patterns and produce prioritized findings with concrete fixes. Patterns sourced from the official .NET performance blog series, distilled to customer-actionable guidance.
When to Use
- Reviewing C#/.NET code for performance optimization opportunities
- Auditing hot paths for allocation-heavy or inefficient patterns
- Systematic scan of a codebase for known anti-patterns before release
- Second-opinion analysis after manual performance review
When Not to Use
- Algorithmic complexity analysis — this skill targets API usage patterns, not algorithm design
- Code not on a hot path with no performance requirements — avoid premature optimization
Inputs
| Input | Required | Description |
|---|---|---|
| Source code | Yes | C# files, code blocks, or repository paths to scan |
| Hot-path context | Recommended | Which code paths are performance-critical |
| Target framework | Recommended | .NET version (some patterns require .NET 8+) |
| Scan depth | Optional | critical-only, standard (default), or comprehensive |
Workflow
Step 1: Load Reference Files (if available)
Try to load references/critical-patterns.md and the topic-specific reference files listed below. These contain detailed detection recipes and grep commands.
If reference files are not found (e.g., in a sandboxed environment or when the skill is embedded as instructions only), skip file loading and proceed directly to Step 3 using the scan recipes listed inline below. Do not spend time searching the filesystem for reference files — if they aren't at the expected relative path, they aren't available.
Step 2: Detect Code Signals and Select Topic Recipes
Scan the code for signals that indicate which pattern categories to check. If reference files were loaded, use their ## Detection sections. Otherwise, use the inline recipes in Step 3.
| Signal in Code | Topic |
|---|---|
async, await, Task, ValueTask | Async patterns |
Span<, Memory<, stackalloc, ArrayPool, string.Substring, .Replace(, .ToLower(), += in loops, params | Memory & strings |
Regex, [GeneratedRegex], Regex.Match, RegexOptions.Compiled | Regex patterns |
Dictionary<, List<, .ToList(), .Where(, .Select(, LINQ methods, static readonly Dictionary< | Collections & LINQ |
JsonSerializer, HttpClient, Stream, FileStream | I/O & serialization |
Always check structural patterns (unsealed classes) regardless of signals.
Scan depth controls scope:
critical-only: Only critical patterns (deadlocks, >10x regressions)standard(default): Critical + detected topic patternscomprehensive: All pattern categories
Step 3: Scan and Report
For files under 500 lines, read the entire file first — you'll spot most patterns faster than running individual grep recipes. Use grep to confirm counts and catch patterns you might miss visually.
For each relevant pattern category, run the detection recipes below. Report exact counts, not estimates.
Core scan recipes (run these when reference files aren't available):
# Strings & memory
grep -n '\.IndexOf(\"' FILE # Missing StringComparison
grep -n '\.Substring(' FILE # Substring allocations
grep -En '\.(StartsWith|EndsWith|Contains)\s*\(' FILE # Missing StringComparison
grep -n '\.ToLower()\|\.ToUpper()' FILE # Culture-sensitive + allocation
grep -n '\.Replace(' FILE # Chained Replace allocations
grep -n 'params ' FILE # params array allocation
# Collections & LINQ
grep -n '\.Select\|\.Where\|\.OrderBy\|\.GroupBy' FILE # LINQ on hot path
grep -n '\.All\|\.Any' FILE # LINQ on string/char
grep -n 'new Dictionary<\|new List<' FILE # Per-call allocation
grep -n 'static readonly Dictionary<' FILE # FrozenDictionary candidate
# Regex
grep -n 'RegexOptions.Compiled' FILE # Compiled regex budget
grep -n 'new Regex(' FILE # Per-call regex
grep -n 'GeneratedRegex' FILE # Positive: source-gen regex
# Structural
grep -n 'public class \|internal class ' FILE # Unsealed classes
grep -n 'sealed class' FILE # Already sealed
grep -n ': IEquatable' FILE # Positive: struct equalityRules:
- Run every relevant recipe for the detected pattern categories
- Emit a scan execution checklist before classifying findings — list each recipe and the hit count
- A result of 0 hits is valid and valuable (confirms good practice)
- If reference files were loaded, also run their
## Detectionrecipes
Verify-the-Inverse Rule: For absence patterns, always count both sides and report the ratio (e.g., "N of M classes are sealed"). The ratio determines severity — 0/185 is systematic, 12/15 is a consistency fix.
Step 3b: Cross-File Consistency Check
If an optimized pattern is found in one file, check whether sibling files (same directory, same interface, same base class) use the un-optimized equivalent. Flag as 🟡 Moderate with the optimized file as evidence.
Step 3c: Compound Allocation Check
After running scan recipes, look for these multi-allocation patterns that single-line recipes miss:
1. Branched `.Replace()` chains: Methods that call .Replace() across multiple if/else branches — report total allocation count across all branches, not just per-line. 2. Cross-method chaining: When a public method delegates to another method that itself allocates intermediates (e.g., A calls B which does 3 regex replaces, then A calls C), report the total chain cost as one finding. 3. Compound `+=` with embedded allocating calls: Lines like result += $"...{Foo().ToLower()}" are 2+ allocations (interpolation + ToLower + concatenation) — flag the compound cost, not just the .ToLower(). 4. `string.Format` specificity: Distinguish resource-loaded format strings (not fixable) from compile-time literal format strings (fixable with interpolation). Enumerate the actionable sites.
Step 4: Classify and Prioritize Findings
Assign each finding a severity:
| Severity | Criteria | Action |
|---|---|---|
| 🔴 Critical | Deadlocks, crashes, security vulnerabilities, >10x regression | Must fix |
| 🟡 Moderate | 2-10x improvement opportunity, best practice for hot paths | Should fix on hot paths |
| ℹ️ Info | Pattern applies but code may not be on a hot path | Consider if profiling shows impact |
Prioritization rules: 1. If the user identified hot-path code, elevate all findings in that code to their maximum severity 2. If hot-path context is unknown, report 🔴 Critical findings unconditionally; report 🟡 Moderate findings with a note: _"Impactful if this code is on a hot path"_ 3. Never suggest micro-optimizations on code that is clearly not performance-sensitive
Scale-based severity escalation: When the same pattern appears across many instances, escalate severity:
- 1-10 instances of the same anti-pattern → report at the pattern's base severity
- 11-50 instances → escalate ℹ️ Info patterns to 🟡 Moderate
- 50+ instances → escalate to 🟡 Moderate with elevated priority; flag as a codebase-wide systematic issue
Always report exact counts (from scan recipes), not estimates or agent summaries.
Step 5: Generate Findings
Keep findings compact. Each finding is one short block — not an essay. Group by severity (🔴 → 🟡 → ℹ️), not by file.
Format per finding:
#### ID. Title (N instances)
**Impact:** one-line impact statement
**Files:** file1.cs:L1, file2.cs:L2, ... (list locations, don't build tables)
**Fix:** one-line description of the change (e.g., "Add `StringComparison.Ordinal` parameter")
**Caveat:** only if non-obvious (version requirement, correctness risk)Rules for compact output:
- No ❌/✅ code blocks for trivial fixes (adding a keyword, parameter, or type change). A one-line fix description suffices.
- Only include code blocks for non-obvious transformations (e.g., replacing a LINQ chain with a foreach loop, or hoisting a closure).
- File locations as inline comma-separated list, not a table. Use
File.cs:L42format. - No explanatory prose beyond the Impact line — the severity icon already conveys urgency.
- Merge related findings that share the same fix (e.g., all
.ToLower()calls go in one finding, not split by file). - Positive findings in a bullet list, not a table. One line per pattern:
✅ Pattern — evidence.
End with a summary table and disclaimer:
| Severity | Count | Top Issue |
|----------|-------|-----------|
| 🔴 Critical | N | ... |
| 🟡 Moderate | N | ... |
| ℹ️ Info | N | ... |
> ⚠️ **Disclaimer:** These results are generated by an AI assistant and are non-deterministic. Findings may include false positives, miss real issues, or suggest changes that are incorrect for your specific context. Always verify recommendations with benchmarks and human review before applying changes to production code.Validation
Before delivering results, verify:
- [ ] All critical patterns were checked (from reference files or inline recipes)
- [ ] Topic-specific recipes run only when matching signals detected
- [ ] Each finding includes a concrete code fix
- [ ] Scan execution checklist is complete (all recipes run)
- [ ] Summary table included at end
Common Pitfalls
| Pitfall | Correct Approach |
|---|---|
Flagging every Dictionary as needing FrozenDictionary | Only flag if the dictionary is never mutated after construction |
Suggesting Span<T> in async methods | Use Memory<T> in async code; Span<T> only in sync hot paths |
| Reporting LINQ outside hot paths | Only flag LINQ in identified hot paths or tight loops; LINQ is acceptable in code that runs infrequently. Since .NET 7, LINQ Min/Max/Sum/Average are vectorized — blanket bans on LINQ are misguided |
Suggesting ConfigureAwait(false) in app code | Only applicable in library code; not primarily a performance concern |
Recommending ValueTask everywhere | Only for hot paths with frequent synchronous completion |
Flagging new HttpClient() in DI services | Check if IHttpClientFactory is already in use |
Suggesting [GeneratedRegex] for dynamic patterns | Only flag when the pattern string is a compile-time literal |
Suggesting CollectionsMarshal.AsSpan broadly | Only for ultra-hot paths with benchmarked evidence; adds complexity and fragility |
Suggesting unsafe code for micro-optimizations | Avoid unsafe except where absolutely necessary — do not recommend it for micro-optimizations that don't matter. Safe alternatives like Span<T>, stackalloc in safe context, and ArrayPool cover the vast majority of performance needs |
{
"version": "0.1.0",
"category": "Metrics",
"compatibility": "Requires a .NET repository, build artifacts, traces, dumps, or a runnable app for diagnostics work."
}
Async & Concurrency Patterns
Don't Expose Async Wrappers for Sync Methods
🟡 AVOID wrapping sync methods with Task.Run in libraries | .NET Core+
❌
public Task<int> ComputeHashAsync(byte[] data) =>
Task.Run(() => ComputeHash(data));✅
public int ComputeHash(byte[] data) { /* CPU-bound work */ }
// Consumer decides: var hash = await Task.Run(() => lib.ComputeHash(data));Impact: Eliminates unnecessary thread pool queue/dequeue overhead per call.
Don't Expose Sync Wrappers for Async Methods
🟡 AVOID creating sync wrappers that block on async implementations | .NET Core+
❌
public string GetData() => GetDataAsync().Result;✅
public async Task<string> GetDataAsync() { /* ... */ }Impact: Prevents deadlocks and thread pool starvation from hidden sync-over-async blocking.
Use ValueTask for Hot Paths with Frequent Sync Completion
🟡 DO use ValueTask<T> on hot paths where sync completion is common | .NET Core 2.1+
❌
public async Task<int> ReadAsync(Memory<byte> buffer)
{
if (_bufferedCount > 0)
return ReadFromBuffer(buffer.Span);
return await ReadAsyncCore(buffer);
}✅
public ValueTask<int> ReadAsync(Memory<byte> buffer)
{
if (_bufferedCount > 0)
return new ValueTask<int>(ReadFromBuffer(buffer.Span));
return new ValueTask<int>(ReadAsyncCore(buffer));
}Impact: Eliminates Task\<T\> allocation on synchronous completion — the struct stores results inline.
Use Channels for Producer/Consumer
🟡 DO use System.Threading.Channels for producer-consumer patterns | .NET Core 3.0+
❌
var queue = new BlockingCollection<WorkItem>();
var item = queue.Take();✅
var channel = Channel.CreateUnbounded<WorkItem>();
// Producer
await channel.Writer.WriteAsync(item);
// Consumer
await foreach (var item in channel.Reader.ReadAllAsync())
Process(item);Impact: ~25% faster, ~95% fewer GC collections vs manual approaches.
Avoid False Sharing with Thread-Local State
🟡 AVOID adjacent mutable fields written by different threads | .NET 7+
❌
class SharedCounters
{
public long Counter1;
public long Counter2;
}✅
[StructLayout(LayoutKind.Explicit, Size = 128)]
struct PaddedCounter
{
[FieldOffset(0)] public long Value;
}Impact: Eliminates cross-core cache invalidation — can improve multi-threaded throughput by 10x+.
Detection
Scan recipes for async anti-patterns. Run these and report exact counts.
# async void methods (correctness issue — crashes on exception)
grep -rn --include='*.cs' 'async void' --exclude-dir=bin --exclude-dir=obj . | grep -v 'event' | wc -lPatterns Requiring Manual Review
- Sync-over-async (
.Result,.Wait()):.Resultmatches any property named Result — needs type context to confirm it'sTask.Result
Collections & LINQ Patterns
Use FrozenDictionary/FrozenSet for Read-Heavy Lookup Tables
🟡 DO use FrozenDictionary/FrozenSet for collections created once and read many times | .NET 8+
❌
private static readonly Dictionary<string, int> s_statusCodes = new()
{
["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500
};✅
private static readonly FrozenDictionary<string, int> s_statusCodes =
new Dictionary<string, int>
{
["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500
}.ToFrozenDictionary();Impact: ~50% faster lookups than Dictionary, ~14x faster than ImmutableDictionary.
Use Dictionary Alternate Lookup for Span-Based Keys
🟡 DO use GetAlternateLookup<ReadOnlySpan<char>>() to avoid string allocation on lookups | .NET 9+
❌
string key = headerLine.Substring(0, colonIndex);
if (s_dict.TryGetValue(key, out int value)) { /* ... */ }✅
var lookup = s_dict.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> key = headerLine.AsSpan(0, colonIndex);
if (lookup.TryGetValue(key, out int value)) { }Impact: Avoids string allocation per lookup — especially valuable in parser/protocol hot paths.
Use CollectionsMarshal.GetValueRefOrNullRef for Lookup-and-Update
🟡 DO use CollectionsMarshal.GetValueRefOrAddDefault for dictionary update patterns | .NET 6+
❌
_counts.TryGetValue(key, out int count);
_counts[key] = count + 1;✅
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(_counts, key, out _);
count++;Impact: ~48% faster for lookup-and-update patterns (95µs → 49µs).
Use Collection Expressions [] for Zero-Allocation Span Creation
🟡 DO use collection expressions for Span<T> targets | C# 12 / .NET 8+
❌
int[] values = new int[] { a, b, c, d };✅
Span<int> values = [a, b, c, d];
ReadOnlySpan<int> daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];Impact: Zero heap allocation for span-targeted collection expressions.
Use EnsureCapacity on List/Stack/Queue Before Bulk Adds
🟡 DO call EnsureCapacity before bulk insertions | .NET 6+
❌
var list = new List<int>();
for (int i = 0; i < 10000; i++)
list.Add(i);✅
var list = new List<int>();
list.EnsureCapacity(10000);
for (int i = 0; i < 10000; i++)
list.Add(i);Impact: Reduces reallocations and array copies during bulk operations.
Use TryGetNonEnumeratedCount for Pre-Sizing
🟡 DO use TryGetNonEnumeratedCount to pre-size destination collections | .NET 6+
❌
var results = new List<int>();
foreach (var item in source)
results.Add(Transform(item));✅
var results = source.TryGetNonEnumeratedCount(out int count)
? new List<int>(count)
: new List<int>();
foreach (var item in source)
results.Add(Transform(item));Impact: Avoids O(n) enumeration for counting; eliminates resizing allocations.
Hoist Static Data Out of Method Bodies
🟡 AVOID creating collections with static/deterministic data inside method bodies | .NET Core+
❌
public string Convert(long number)
{
var groupsMap = new Dictionary<long, Func<long, string>>
{
{ 1_000_000_000, n => $"{Convert(n)} billion" },
{ 1_000_000, n => $"{Convert(n)} million" },
{ 1_000, n => $"{Convert(n)} thousand" },
};
}✅
private static readonly FrozenDictionary<long, Func<long, string>> s_groupsMap =
new Dictionary<long, Func<long, string>>
{
{ 1_000_000_000, n => $"{Convert(n)} billion" },
{ 1_000_000, n => $"{Convert(n)} million" },
{ 1_000, n => $"{Convert(n)} thousand" },
}.ToFrozenDictionary();
public string Convert(long number)
{
// ... use s_groupsMap
}Impact: Eliminates collection + internal storage + closure allocations per call. For a Dictionary with N entries, saves ~N+3 allocations per invocation.
Add Overloads to Avoid params Array Allocation
🟡 DO add 1- and 2-argument overloads for methods that accept params T[], or use params ReadOnlySpan<T> on .NET 9+ | .NET Core+
❌
public static string Transform(this string input, params IStringTransformer[] transformers) =>
transformers.Aggregate(input, (current, t) => t.Transform(current));
"hello".Transform(To.TitleCase);✅
// Option A: Explicit overloads for common arities
public static string Transform(this string input, IStringTransformer transformer) =>
transformer.Transform(input);
public static string Transform(this string input, IStringTransformer t1, IStringTransformer t2) =>
t2.Transform(t1.Transform(input));
public static string Transform(this string input, params IStringTransformer[] transformers) =>
transformers.Aggregate(input, (current, t) => t.Transform(current));
// Option B (.NET 9+ / C# 13): params ReadOnlySpan<T> — zero heap allocation
public static string Transform(this string input, params ReadOnlySpan<IStringTransformer> transformers)
{
foreach (var t in transformers)
input = t.Transform(input);
return input;
}Impact: Eliminates one array allocation per call for the common 1- and 2-argument cases. `params ReadOnlySpan<T>` eliminates it for all arities.
Detection
Scan recipes for collection and LINQ anti-patterns. Run these and report exact counts.
# Static Dictionary not using FrozenDictionary (read-only after init)
grep -rn --include='*.cs' 'static readonly Dictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Static FrozenDictionary (already optimized — verify the inverse)
grep -rn --include='*.cs' 'static readonly FrozenDictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Per-call List allocation (inside method bodies, not static/readonly fields)
grep -rn --include='*.cs' 'new List<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l
# Per-call Dictionary allocation (inside method bodies, not static/readonly fields)
grep -rn --include='*.cs' 'new Dictionary<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l
# StringComparer.CurrentCulture usage (almost always wrong in library code — use Ordinal)
grep -rn --include='*.cs' 'StringComparer.CurrentCulture' --exclude-dir=bin --exclude-dir=obj . | wc -l
# LINQ chains in extension/hot-path files (.Select, .Where, .Cast, .Take, .Aggregate)
grep -rn --include='*.cs' -E '\.(Select|Where|Cast|Take|Aggregate)\(' --exclude-dir=bin --exclude-dir=obj . | wc -lFor the LINQ chain recipe: any hit in a file whose name ends in Extensions.cs, Formatter.cs, or implements a method called from a public extension method is a hot-path candidate. Inspect each hit in these files and flag LINQ chains that allocate delegates, enumerators, or intermediate collections on every call. Hits in localization converters or one-time initialization are lower priority.
Patterns Requiring Manual Review
- ContainsKey + indexer double-lookup: Requires verifying the same key is used in a subsequent indexer access — multi-line/multi-statement context
- LINQ on hot paths: The LINQ chain recipe above catches call sites, but distinguishing hot-path from cold-path requires context. Prioritize hits in
*Extensions.csand*Formatter.csfiles, which are typically called on every user invocation - `new Dictionary/List<` in method bodies vs fields: The grep heuristic (
grep -v 'static\|readonly') catches most cases but may include false positives from field initializers withoutstatic/readonly— spot-check flagged lines
Critical .NET Performance Anti-Patterns
17 patterns that cause deadlocks, order-of-magnitude regressions, or excessive allocations.
Async / Tasks
Never Block on Async (Sync-over-Async)
🔴 AVOID | .NET Core+
❌
public string GetData()
=> GetDataAsync().Result;✅
public async Task<string> GetDataAsync()
=> await GetDataInternalAsync();Impact: Deadlocks or thread pool starvation; wastes threads, destroys scalability.
Never Await a ValueTask Multiple Times
🔴 AVOID | .NET Core 2.1+
❌
ValueTask<int> vt = SomeMethodAsync();
int a = await vt;
int b = await vt;✅
int result = await SomeMethodAsync();Impact: Undefined behavior — silent data corruption or exceptions.
Memory / Allocation
Use Span\<T\> / AsSpan Instead of Substring for Slicing
🔴 DO | .NET Core 2.1+
❌
string sub = input.Substring(5, 10);✅
ReadOnlySpan<char> sub = input.AsSpan(5, 10);Impact: Eliminates per-slice allocations; 2-4x faster via vectorization.
Use ArrayPool\<T\> for Temporary Buffers
🔴 DO | .NET Core+
❌
byte[] buf = new byte[4096];✅
byte[] buf = ArrayPool<byte>.Shared.Rent(4096);
Process(buf);
ArrayPool<byte>.Shared.Return(buf);Impact: Dramatically reduces GC pressure for buffer-heavy workloads.
Avoid stackalloc in Loops
🔴 AVOID | .NET 5+
❌
for (int i = 0; i < 10_000; i++)
Span<byte> buf = stackalloc byte[1024];✅
Span<byte> buf = stackalloc byte[1024];
for (int i = 0; i < 10_000; i++) { Process(buf); }Impact: StackOverflowException — unrecoverable, no catch possible.
Avoid Boxing Value Types
🔴 AVOID | .NET 6+
❌
string s = string.Format("{0}.{1}", major, minor);✅
string s = $"{major}.{minor}";Impact: When replacing `string.Format` with C# 10+ interpolation, typical improvements are ~40% faster with significantly less allocation. Actual gains vary by call site.
Strings
Use StringComparison.Ordinal for Non-Linguistic Comparisons
🔴 DO | .NET Core+
❌
bool found = text.IndexOf("Content-Type") >= 0;✅
bool found = text.Contains("Content-Type", StringComparison.Ordinal);Impact: 2-3x faster; OrdinalIgnoreCase hash codes ~3.3x faster.
Use AsSpan Instead of Substring
🔴 DO | .NET Core 2.1+
❌
int val = int.Parse(str.Substring(5, 3));✅
int val = int.Parse(str.AsSpan(5, 3));Impact: Eliminates one string allocation per parse operation.
Regular Expressions
Use Source-Generated Regex [GeneratedRegex]
🔴 ALWAYS use [GeneratedRegex] for all static regex patterns | .NET 7+
❌
private static readonly Regex s_re =
new(@"\w+@\w+\.\w+", RegexOptions.Compiled);✅
[GeneratedRegex(@"\w+@\w+\.\w+")]
private static partial Regex EmailRegex();Impact: Always beneficial or neutral for static patterns — near-zero startup, better throughput, and required for AOT/trimming scenarios.
Avoid Nested Quantifiers (Catastrophic Backtracking)
🔴 AVOID | .NET Core+
❌
var r = new Regex(@"^(\w+)+$");✅
var r = new Regex(@"^\w+$", RegexOptions.NonBacktracking);Impact: Can hang process indefinitely on crafted input.
Use TryGetValue Instead of ContainsKey + Indexer
🔴 DO | .NET Core+
❌
if (dict.ContainsKey(key))
Use(dict[key]);✅
if (dict.TryGetValue(key, out var value))
Use(value);Impact: ~2x faster (50% reduction in lookup time).
Avoid LINQ in Hot Paths
🔴 AVOID | .NET Core+
❌
bool found = items.Any(x => x.Name == target);✅
bool found = false;
foreach (var item in items)
if (item.Name == target) { found = true; break; }Impact: Eliminates 1-3 allocations per call; measurable in tight loops.
Don't Iterate IEnumerable Multiple Times
🔴 AVOID | .NET Core+
❌
foreach (Type t in types) { Validate(t); }
_types = types.ToArray();✅
Type[] arr = types.ToArray();
foreach (Type t in arr) { Validate(t); }
_types = arr;Impact: Halves enumeration cost; prevents bugs from re-executing deferred queries.
JSON Serialization
Use System.Text.Json Source Generator
🔴 DO | .NET 6+
❌
string json = JsonSerializer.Serialize(post);✅
[JsonSerializable(typeof(BlogPost))]
internal partial class AppJsonCtx : JsonSerializerContext { }
string json = JsonSerializer.Serialize(post, AppJsonCtx.Default.BlogPost);Impact: 37-44% faster; enables trimming and Native AOT.
Cache JsonSerializerOptions
🔴 DO | .NET 5+
❌
JsonSerializer.Serialize(obj, new JsonSerializerOptions());✅
private static readonly JsonSerializerOptions s_opts = new();
JsonSerializer.Serialize(obj, s_opts);Impact: Up to 592x slower without caching (.NET 6); always cache or use defaults.
Networking
Reuse HttpClient Instances
🔴 DO | .NET Core 2.1+
❌
using var client = new HttpClient();
await client.GetStringAsync(url);✅
private static readonly HttpClient s_http = new(new SocketsHttpHandler
{ PooledConnectionLifetime = TimeSpan.FromMinutes(5) });
await s_http.GetStringAsync(url);Impact: Prevents socket exhaustion; 6-12x faster concurrent HTTPS.
General
Use SearchValues\<T\> for Repeated Set Searches
🔴 DO | .NET 8+
❌
int pos = text.IndexOfAny("ABCDEF".ToCharArray());✅
private static readonly SearchValues<char> s_hex = SearchValues.Create("ABCDEF");
int pos = text.AsSpan().IndexOfAny(s_hex);Impact: 2-10x faster for chars; 10-30x faster for multi-string (.NET 9+).
Detection
Scan recipes for critical anti-patterns. Run these and report exact counts of issues found in each case.
# .IndexOf(string) without StringComparison (culture-aware, 2-3x slower)
grep -rn --include='*.cs' -E '\.IndexOf\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
# .Substring( calls (allocates new string — consider AsSpan)
grep -rn --include='*.cs' '\.Substring(' --exclude-dir=bin --exclude-dir=obj . | wc -l
# .StartsWith/.EndsWith without StringComparison (culture-aware, 2-3x slower)
grep -rn --include='*.cs' -E '\.(StartsWith|EndsWith)\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
# .Contains(string) without StringComparison — NOTE: will also match collection .Contains() calls; filter to string receivers
grep -rn --include='*.cs' -E '\.Contains\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -lI/O, Serialization & General Patterns
Use HttpCompletionOption.ResponseHeadersRead for Streaming
🟡 DO use ResponseHeadersRead when downloading large responses | .NET Core 3.0+
❌
var response = await client.GetAsync(uri);✅
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
using var stream = await response.Content.ReadAsStreamAsync();
await stream.CopyToAsync(destinationStream);Impact: ~2x faster for large downloads (10MB+), dramatically reduced memory usage.
Use Async FileStream Operations
🟡 DO use FileStream with useAsync: true for scalable file I/O | .NET 6+
❌
using var fs = new FileStream(path, FileMode.Open);✅
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.Read, bufferSize: 4096, useAsync: true);
byte[] buffer = new byte[1024];
while (await fs.ReadAsync(buffer) != 0) { /* process */ }Impact: Up to 3x faster async reads; allocation reduced from megabytes to hundreds of bytes.
Use Memory\<byte\> Overloads for Stream.ReadAsync/WriteAsync
🟡 DO use Memory<byte>-based stream overloads instead of byte[] overloads | .NET 5+
❌
await stream.ReadAsync(buffer, 0, buffer.Length);
await stream.WriteAsync(buffer, 0, buffer.Length);✅
await stream.ReadAsync(buffer.AsMemory());
await stream.WriteAsync(buffer.AsMemory());Impact: Eliminates ~72 KB allocation per 1,000 read/write pairs on NetworkStream.
Use Span-Based TryFormat for Number Formatting
🟡 DO use TryFormat to format numbers into Span<char> buffers | .NET Core 2.1+
❌
string formatted = value.ToString();
destination.Write(formatted);✅
Span<char> buffer = stackalloc char[20];
if (value.TryFormat(buffer, out int charsWritten))
destination.Write(buffer[..charsWritten]);Impact: Int32.ToString() ~2x faster in .NET Core 2.1, Int32 parsing ~5x faster in .NET Core 3.0.
Use static readonly for Runtime Devirtualization
🟡 DO store implementations in static readonly fields for JIT devirtualization | .NET Core 3.0+
❌
private static Base s_impl = new DerivedImpl();
s_impl.Process();✅
private static readonly Base s_impl = new DerivedImpl();
s_impl.Process();
private static readonly bool s_feature =
Environment.GetEnvironmentVariable("Feature") == "1";Impact: Virtual call eliminated entirely — can be inlined to zero overhead. Dead code elimination in tier 1.
Avoid Explicit Static Constructors — Use Field Initializers
🟡 AVOID explicit static constructors when field initializers suffice | .NET Core 3.0+
❌
class Foo
{
static readonly int s_value;
static Foo() { s_value = ComputeValue(); }
}✅
class Foo
{
static readonly int s_value = ComputeValue();
}Impact: Enables better JIT optimization and reduces potential lock overhead on static method access.
Detection
Scan recipes for I/O and serialization anti-patterns. Run these and report exact counts.
# new HttpClient() (socket exhaustion risk)
grep -rn --include='*.cs' 'new HttpClient(' --exclude-dir=bin --exclude-dir=obj . | wc -l
# new JsonSerializerOptions() not cached (592x slower in .NET 6)
grep -rn --include='*.cs' 'new JsonSerializerOptions' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -lPatterns Requiring Manual Review
- `JsonSerializer.Serialize/Deserialize` without source-gen context: Can't determine from grep if a context parameter is passed
Memory & String Patterns
Use ReadOnlySpan\<byte\> for Constant Byte Data
🟡 DO assign constant byte arrays to ReadOnlySpan<byte> | .NET 5+
❌
byte[] data = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F };✅
ReadOnlySpan<byte> data = [0x48, 0x65, 0x6C, 0x6C, 0x6F];
ReadOnlySpan<int> primes = [2, 3, 5, 7, 11, 13];Impact: ~100x faster access than static byte[] field, zero allocation.
Use stackalloc for Small Temporary Buffers
🟡 DO use stackalloc for small, fixed-size temporary buffers | .NET Core+
❌
char[] buffer = new char[64];
guid.TryFormat(buffer, out int written);✅
Span<char> buffer = stackalloc char[64];
guid.TryFormat(buffer, out int written);Impact: Zero heap allocation, no GC pressure, instant alloc/dealloc.
Use Span.TryWrite for Allocation-Free Interpolation
🟡 DO use MemoryExtensions.TryWrite to format into Span<char> buffers | .NET 6+
❌
string formatted = $"Date: {dt:R}";
destination.Write(formatted);✅
Span<char> buffer = stackalloc char[64];
buffer.TryWrite($"Date: {dt:R}", out int charsWritten);Impact: Zero heap allocation for formatting operations.
Use Span.Split() for Zero-Allocation Splitting
🟡 DO use MemoryExtensions.Split for allocation-free string splitting | .NET 9+
❌
string[] parts = input.Split(',');✅
foreach (Range range in input.AsSpan().Split(','))
{
ReadOnlySpan<char> segment = input.AsSpan(range);
}Impact: 208 bytes → 0 bytes per split, 2x faster.
Use UTF8 String Literals (u8 suffix)
🟡 DO use the u8 suffix for compile-time UTF8 ReadOnlySpan<byte> | .NET 7+
❌
byte[] header = Encoding.UTF8.GetBytes("Content-Type");✅
ReadOnlySpan<byte> header = "Content-Type"u8;Impact: 17ns → 0.006ns — eliminates runtime transcoding entirely.
Use ReadOnlySpan\<char\> Pattern Matching with switch
🟡 DO use switch on ReadOnlySpan<char> for allocation-free string matching | C# 11+
❌
switch (attr.Value.Trim()) { case "preserve": /* ... */ break; }✅
switch (attr.Value.AsSpan().Trim())
{
case "preserve": return Preserve;
case "default": return Default;
}Impact: Eliminates string allocation from Trim() in switch-based dispatch.
Use params ReadOnlySpan\<T\> to Eliminate Array Allocations
🟡 DO add params ReadOnlySpan<T> overloads to library methods | C# 13 / .NET 9+
❌
public static void Log(params string[] messages) { /* ... */ }
Log("Starting", "Processing", "Done");✅
public static void Log(params ReadOnlySpan<string> messages) { /* ... */ }
Log("Starting", "Processing", "Done");Impact: Eliminates params array allocation. E.g., Path.Join with 5+ segments saves 64 bytes per call.
Avoid Chained String-Returning Operations
🟡 AVOID chains of 3+ string-returning method calls that each allocate intermediates | .NET Core+
Pattern 1: Chained .Replace() calls
❌
string result = input.Replace("a", "b").Replace("c", "d").Replace("e", "f");✅
var sb = new StringBuilder(input.Length);
// single pass replacing all patternsPattern 2: Chained Regex.Replace() calls
❌
public static string Underscore(this string input) =>
Regex3.Replace(Regex2.Replace(Regex1.Replace(input, "$1_$2"), "$1_$2"), "_").ToLower();✅
return string.Create(totalLength, state, (span, s) => { /* write directly */ });Pattern 3: += string concatenation in loops
❌
string result = "";
foreach (var part in parts)
result += separator + part;✅
var sb = new StringBuilder();
foreach (var part in parts)
sb.Append(separator).Append(part);
return sb.ToString();Impact: Eliminates N-1 intermediate string allocations per chain. For `+=` in loops, eliminates O(n²) total allocation.
Cache char.ToString() for Known Character Sets
🟡 DO cache char.ToString() results when the set of characters is small and known | .NET Core+
❌
return symbol.ToString();
foreach (var prefix in UnitPrefixes)
input = input.Replace(prefix.Value.Name, prefix.Key.ToString());✅
private static readonly FrozenDictionary<char, string> s_charStrings =
new Dictionary<char, string>
{
['k'] = "k", ['M'] = "M", ['G'] = "G",
}.ToFrozenDictionary();
return s_charStrings[symbol];Impact: Eliminates one string allocation per char.ToString() call. Significant when called in loops or on hot paths.
Detection
Scan recipes for memory and string anti-patterns. Run these and report exact counts.
# .ToLower()/.ToUpper() without culture parameter (allocates + culture-sensitive)
grep -rn --include='*.cs' -E '\.(ToLower|ToUpper)\(\)' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Chained .Replace( calls (3+ on one line — intermediate string allocations)
grep -rn --include='*.cs' '\.Replace(.*\.Replace(.*\.Replace(' --exclude-dir=bin --exclude-dir=obj . | wc -l
# params in method signatures (array allocation per call)
grep -rn --include='*.cs' 'params ' --exclude-dir=bin --exclude-dir=obj . | wc -l
# LINQ on strings — .All/.Any on IEnumerable<char> (replace with foreach loop)
grep -rn --include='*.cs' -E '\.(All|Any)\(char\.' --exclude-dir=bin --exclude-dir=obj . | wc -lPatterns Requiring Manual Review
- Boxing via string.Format: Can't determine argument types from grep — needs type analysis
- `+=` string concatenation in loops:
+=matches all types (int, list, event, string) — needs type context to confirm string - `char.ToString()`: Requires knowing the variable type is
char— not reliably greppable
Regex Patterns
Choose the Right Regex Engine Mode
🟡 DO use [GeneratedRegex] for all static regex patterns, but never remove NonBacktracking if present | .NET 7+
❌
var r = new Regex(dynamicPattern, RegexOptions.Compiled);✅
[GeneratedRegex("pattern")]
private static partial Regex MyRegex();
var safe = new Regex(untrustedPattern, RegexOptions.NonBacktracking);
var oneOff = new Regex("pattern");Impact: Source generator is always beneficial for static patterns. NonBacktracking prevents O(2^N) worst case — never remove it if present.
Use IsMatch When You Only Need a Boolean Result
🟡 DO use IsMatch instead of Match(...).Success | .NET 7+
❌
bool found = Regex.Match(input, pattern).Success;✅
bool found = Regex.IsMatch(input, pattern);Impact: Avoids Match object allocation; with NonBacktracking, ~3x faster by skipping capture computation.
Use Regex.Count/EnumerateMatches Instead of Matches
🟡 DO use Count() and EnumerateMatches() for allocation-free match processing | .NET 7+
❌
int count = 0;
Match m = regex.Match(text);
while (m.Success) { count++; m = m.NextMatch(); }✅
int count = regex.Count(text);
foreach (ValueMatch m in Regex.EnumerateMatches(text, @"\b\w+\b"))
{
ReadOnlySpan<char> word = text.AsSpan(m.Index, m.Length);
}Impact: ~3x faster than Match/NextMatch with NonBacktracking. Zero allocations for both Count and EnumerateMatches.
Use Span-Based Regex APIs for Allocation-Free Matching
🟡 DO use ReadOnlySpan<char> overloads for regex matching on spans | .NET 7+
❌
string sub = largeBuffer.Substring(start, length);
bool found = Regex.IsMatch(sub, pattern);✅
ReadOnlySpan<char> text = largeBuffer.AsSpan(start, length);
foreach (ValueMatch m in Regex.EnumerateMatches(text, @"\b\w+\b"))
{
ReadOnlySpan<char> word = text.Slice(m.Index, m.Length);
}Impact: Eliminates string allocations when working with spans — particularly valuable in high-throughput parsing pipelines.
Detection
Scan recipes for regex anti-patterns. Run these and report exact counts.
# Compiled regex count (startup cost budget — compare ratio to GeneratedRegex)
grep -rn --include='*.cs' 'RegexOptions.Compiled' --exclude-dir=bin --exclude-dir=obj . | wc -l
# GeneratedRegex count (already optimized — verify the inverse)
grep -rn --include='*.cs' 'GeneratedRegex' --exclude-dir=bin --exclude-dir=obj . | wc -l
# Uncached new Regex() calls (construction cost per call)
grep -rn --include='*.cs' 'new Regex(' --exclude-dir=bin --exclude-dir=obj . | wc -lWhen RegexOptions.Compiled appears inside a class constructor or field initializer of an instantiated class (not a static singleton), count how many instances of that class are created at startup to determine total compiled regex budget. For example, if a Rule class compiles a regex in its constructor and 122 rules are registered, that is 122 compiled regexes at startup.
Patterns Requiring Manual Review
- `new Regex(` uncached: Field assignment may span multiple lines — grep on one line is unreliable. Verify that matched instances are stored in
static readonlyfields or[GeneratedRegex].
Structural Patterns
Patterns detected by the absence of a keyword or interface. These require codebase-wide counting scans, not single-file matching.
Seal Classes for Devirtualization
🟡 DO seal all leaf classes (those not subclassed) | .NET Core 3.0+
Sealing lets the JIT devirtualize/inline virtual calls and use pointer comparison for type checks. Every non-abstract, non-static class that is not subclassed should be sealed.
Detection: This is an absence pattern — scan for classes that are NOT sealed.
# Count unsealed (non-abstract, non-static) classes
grep -rn --include='*.cs' -E '^\s*((public|internal|private|protected|file)\s+)?(partial\s+)?class ' --exclude-dir=bin --exclude-dir=obj . | grep -v 'sealed' | grep -v 'abstract' | grep -v 'static' | wc -l
# Count already-sealed classes (verify the inverse)
grep -rn --include='*.cs' 'sealed class' --exclude-dir=bin --exclude-dir=obj . | wc -lExclusions: Do not seal classes that are subclassed elsewhere in the codebase. Identifying base classes requires manual review — grep for : ClassName patterns and cross-reference, but expect false positives from interface implementations and generic constraints.
❌
internal class MyHandler : Base
{ public override int Run() => 42; }✅
internal sealed class MyHandler : Base
{ public override int Run() => 42; }Impact: Virtual calls up to 500x faster; type checks ~25x faster. Severity scales with count.
Scale-based severity:
- 1-10 unsealed leaf classes → ℹ️ Info
- 11-50 unsealed leaf classes → 🟡 Moderate
- 50+ unsealed leaf classes → 🟡 Moderate (elevated priority)