Introduction
If you're like me, you're probably always curious about the internal structures and behaviors of .NET. What gets allocated, what doesn't, and why. today’s post is about a simple yet often overlooked optimization: static lambdas.
If you're unfamiliar with lambdas, or want to understand why they matter, this overview by Microsoft is a good place to start.
Article photo by Marc Renken on Unsplash.
Lambda Captures and Heap Allocations
Let’s say you’re writing code like this:
public async Task ProcessAsync(List<int> values)
{
var processed = values.Select(v => v * 2).ToList();
}
Seems harmless, right? But what if the lambda captures a local variable?
int multiplier = 2;
var processed = values.Select(v => v * multiplier).ToList();
Now you’re capturing a variable. The compiler generates a closure class behind the scenes, which means allocations. If this method is called thousands of times per second in a hot path, maybe that can hurt.
Static Lambdas
Since C# 9, we can define static anonymous functions. Like this:
var processed = values.Select(static v => v * 2).ToList();
Notice the static
. This tells the compiler: “I promise I won’t capture any local variables or instance
members.” The result? No closure, no allocations, better performance. If you try to capture something anyway, it
won't compile.
Also note that the performance gain comes from preventing context capture, not simply from using static
keyword itself.
Enforcing Static Lambdas with RequireStaticDelegate
JetBrains offer a not-so-known attribute: [RequireStaticDelegate]
. If you create APIs or libraries and
want to enforce this optimization from consumers, this is gold. You can add this attribute installing the JetBrains.Annotations package.
public void Process([RequireStaticDelegate] Func<int, int> transformer) { ... }
With this, JetBrains will give users a warning if they pass a non-static lambda. It’s like a performance linting hint baked into your API surface.
Important: this doesn’t prevent compilation. It’s a JetBrains-specific attribute that works in IDE analysis, not a runtime feature. But it’s a useful nudge.
Passing Context with Static Lambdas Using State Overloads
Let’s say you’re building an HTML DSL or similar structure and want to use static lambdas to avoid allocations. The problem? Static lambdas can’t capture any external variables. So how do you pass context into them without breaking the static restriction?
One elegant solution is to expose an overload that takes an explicit state parameter. Here’s what it looks like:
public HtmlBuilder Append(HtmlTag tag, Action<HtmlBuilder> builderAction)
{
var child = new HtmlBuilder(tag);
builderAction.Invoke(child);
Append(child);
return this;
}
public HtmlBuilder Append<TState>(
HtmlTag tag,
TState state,
[RequireStaticDelegate] Action<TState, HtmlBuilder> builderAction)
{
var child = new HtmlBuilder(tag);
builderAction(state, child);
Append(child);
return this;
}
This lets you preserve the performance of static lambdas without losing flexibility. Instead of capturing the state, you pass it explicitly:
builder.Append(
HtmlTag.Div,
(Name: "Gustavo", Pet: "Cat" ),
static (state, div) => {
div.AppendText($"{state.Name} have a {state.Pet} }");
});
No capture, no closure, no allocations and thanks to [RequireStaticDelegate]
, JetBrains tools will
warn you if the lambda isn't static. This pattern gives you the best of both worlds: the safety and performance of
static lambdas, with the flexibility to still pass around contextual data.
It's especially useful when writing fluent builders or anything where you might nest lambdas repeatedly. Without this overload, you'd either fall back to capturing or write more boilerplate to work around it. Also, I recommend doing this only if you are really wanting the micro-optimization of static lambdas.
This is also what Microsoft uses at HybridCache.
Where It Really Matters
- Hot path code
- LINQ-heavy scenarios
- Callbacks, event handlers and lambda parameters where closures are easy to overlook
When to Ignore It
- Everyday application code where clarity matters more than a few MBs of memory
- EF Core queries that rely on captured state
- Places where you're intentionally capturing local context
Conclusion
Static lambdas are a low-effort win, but only when they’re worth the cost. If you're writing performance-sensitive code, especially in hot paths, they're an easy way to avoid hidden allocations. But if you’re working on regular app logic, forcing them everywhere is overkill and will just make your code harder to read and write.
And if you’re writing public APIs and are sure that your consumers will not need external state and want to avoid unnecessary closures, apply the
[RequireStaticDelegate]
attribute only where it is truly needed.
This blog post is not sponsored by JetBrains. But Rider is really better than Visual Studio anyway. See you next article.