Reflection Performance in .NET 10: Benchmarks, Caching, and Delegates
C# reflection performance is one of those topics that generates a lot of hand-waving. "Reflection is slow" is repeated so often it's practically a meme -- but the reality is more nuanced. Some parts of reflection are genuinely expensive. Others are perfectly fine in context. And in .NET 10, you have more tools than ever to optimize the parts that matter.
This article cuts through the noise. We'll look at what specifically makes reflection expensive, explore four practical optimization strategies with code, and help you figure out when reflection is actually fast enough for your use case. We'll also show you how to set up a proper BenchmarkDotNet measurement so you can validate claims with real numbers rather than intuition.
Before diving in, if you want a refresher on what reflection can do, Reflection in C#: 4 Simple But Powerful Code Examples covers the fundamentals well.
Why Reflection Performance Matters -- and When It Doesn't
Let's establish context first. Reflection performance matters most in:
- Hot paths -- code called thousands of times per second (request handlers, serialization loops, per-row data processing)
- Startup-sensitive apps -- services with tight cold-start SLAs where initialization cost is measured
- Memory-constrained environments -- reflection allocations adding GC pressure on every call
Reflection performance is essentially irrelevant in:
- Admin tooling -- CLI tools, migration scripts, one-time configuration
- Test helpers -- setup code that runs once per test
- Infrequent runtime configuration -- loading a config file at startup, wiring up a plugin at launch time
The key insight: the question is never "is reflection fast?" but rather "is this reflection call in a hot path?"
The Cost of Reflection: What's Slow vs What's Fast
Not all reflection operations are equally expensive. It helps to know where the cost actually lives, because the common advice "reflection is slow" treats it as monolithic when the performance profile is more granular than that. Some operations carry significant overhead every time they're called; others are cheap once the initial setup is done.
Expensive Operations
Member lookup -- calls like GetProperty("Name"), GetMethod("Add"), GetProperties(). These walk the CLR's internal metadata tables, apply filtering, and allocate result objects. GetProperties() specifically allocates a new PropertyInfo[] every call.
Late-bound invocation -- calling PropertyInfo.GetValue(), MethodInfo.Invoke(), or ConstructorInfo.Invoke(). These go through boxing of value types, parameter validation, and indirect dispatch. No JIT inlining possible.
First access to a type -- the very first call to typeof(T).GetProperty("X") may trigger JIT work and CLR metadata loading for that type.
Fast Operations (After Initial Setup)
Reading a cached PropertyInfo reference -- it's just a pointer comparison in a dictionary.
Calling a compiled Func<> delegate -- compiled via Expression.Lambda(...).Compile(), this runs at near-JIT-native speed.
Using [UnsafeAccessor] -- zero runtime overhead once JIT-compiled.
The lookup is typically the most expensive part -- but GetValue/Invoke calls can still be significant overhead even after caching the PropertyInfo. Every strategy below separates the one-time setup cost from the per-call cost.
Strategy 1: Cache PropertyInfo and MethodInfo in Static Dictionaries
The simplest and highest-impact change you can make to reflection-heavy code is caching the results of member lookup. Every call to GetProperty(), GetMethod(), or GetProperties() walks the CLR metadata tables and allocates result objects. Stop doing that on every call and do it once at class-load time or on first access.
Before Caching
public class UserMapper
{
public Dictionary<string, object?> Map(User user)
{
var result = new Dictionary<string, object?>();
// GetProperties() allocates a new array every call
foreach (var prop in user.GetType().GetProperties())
{
result[prop.Name] = prop.GetValue(user);
}
return result;
}
}
Every call to Map() allocates a PropertyInfo[] and performs a full metadata walk. For a User object with ten properties, that's ten individual GetValue calls plus the array allocation -- repeated on every call.
After Caching
public class UserMapper
{
private static readonly PropertyInfo[] _userProps =
typeof(User).GetProperties(BindingFlags.Public | BindingFlags.Instance);
public Dictionary<string, object?> Map(User user)
{
var result = new Dictionary<string, object?>(_userProps.Length);
foreach (var prop in _userProps)
{
result[prop.Name] = prop.GetValue(user);
}
return result;
}
}
The PropertyInfo[] is built once when the class is first loaded. GetValue() still has late-bound overhead, but the lookup is eliminated entirely.
Generic Multi-Type Cache
When your code handles a range of types rather than a single known type, a ConcurrentDictionary<Type, PropertyInfo[]> keyed by Type gives you per-type caching with thread-safe initialization. The first call for any given type pays the metadata cost; all subsequent calls hit the dictionary and return immediately:
public static class TypeCache
{
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _props = new();
public static PropertyInfo[] GetProperties(Type type) =>
_props.GetOrAdd(type, t =>
t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}
ConcurrentDictionary.GetOrAdd is thread-safe and will only call the factory for the first access per key.
Strategy 2: Upgrade to FrozenDictionary (available since .NET 8, fully supported in .NET 10)
ConcurrentDictionary<TKey, TValue> is designed for concurrent read/write scenarios. When your cache is built once at startup and never modified afterward, it carries unnecessary overhead -- internal locking mechanisms and a data structure optimized for mutation.
FrozenDictionary<TKey, TValue> from System.Collections.Frozen is the .NET answer for immutable read-only dictionaries. It's available since .NET 8 and is fully supported in .NET 10. After construction it's immutable, and lookups can use a perfect-hash strategy internally for better average-case performance.
using System.Collections.Frozen;
// Build at startup
public static class ReflectionCache
{
// Types to pre-scan (could come from assembly scanning, DI config, etc.)
private static readonly FrozenDictionary<Type, PropertyInfo[]> _props;
static ReflectionCache()
{
var types = new[] { typeof(User), typeof(Order), typeof(Product) };
_props = types.ToFrozenDictionary(
t => t,
t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}
public static PropertyInfo[]? GetProperties(Type type) =>
_props.TryGetValue(type, out var props) ? props : null;
}
If you're discovering types dynamically (such as through assembly scanning), build the FrozenDictionary after the discovery phase completes. See Assembly Scanning in Needlr: Filtering and Organizing Type Discovery for how a production framework approaches type discovery at startup.
Strategy 3: Compiled Delegates via Expression.Lambda
Caching PropertyInfo eliminates the lookup cost. But GetValue() still uses late-bound invocation -- boxing, parameter validation, indirect dispatch. For truly hot paths, compile a delegate instead.
The idea: use System.Linq.Expressions to build a strongly-typed expression tree, then Compile() it to a Func<>. After compilation, calling the delegate is essentially a JIT-compiled method call.
Compiled Property Getter
BuildGetter takes a PropertyInfo and returns a strongly-typed Func<TObject, TProperty> that the JIT can treat as a normal function call. Call Compile() once at startup and store the resulting delegate in a static field or cache dictionary -- from that point on, every property read pays only the cost of a function call with no boxing or metadata lookup:
public static Func<TObject, TProperty> BuildGetter<TObject, TProperty>(
PropertyInfo property)
{
var param = Expression.Parameter(typeof(TObject), "obj");
var body = Expression.Property(param, property);
var convert = Expression.Convert(body, typeof(TProperty));
return Expression.Lambda<Func<TObject, TProperty>>(convert, param).Compile();
}
Usage:
PropertyInfo nameProp = typeof(User).GetProperty("Name")!;
// Build once, cache in a static field or dictionary
Func<User, string> getName = BuildGetter<User, string>(nameProp);
// Call many times -- near-native performance
string name = getName(user);
Compiled Property Setter
The setter equivalent works the same way -- build the expression tree once, compile it to an Action<TObject, TProperty>, cache and reuse. Value types are not boxed because the delegate is generic, so the JIT generates a specialized version for each concrete type combination:
public static Action<TObject, TProperty> BuildSetter<TObject, TProperty>(
PropertyInfo property)
{
var param = Expression.Parameter(typeof(TObject), "obj");
var valueParam = Expression.Parameter(typeof(TProperty), "value");
var body = Expression.Assign(Expression.Property(param, property), valueParam);
return Expression.Lambda<Action<TObject, TProperty>>(body, param, valueParam).Compile();
}
Untyped Getter (for Generic Property Maps)
When you don't know the property type at compile time:
public static Func<object, object?> BuildUntypedGetter(PropertyInfo property)
{
var param = Expression.Parameter(typeof(object), "obj");
var cast = Expression.Convert(param, property.DeclaringType!);
var access = Expression.Property(cast, property);
var box = Expression.Convert(access, typeof(object));
return Expression.Lambda<Func<object, object?>>(box, param).Compile();
}
This pattern is used in high-performance serializers and ORM column readers. The overhead per call drops from "method dispatch + boxing per call" to "JIT-inlineable function call."
For how this applies to constructor invocation specifically, see ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation.
Strategy 4: UnsafeAccessor Attribute (.NET 8+)
[UnsafeAccessor] is a .NET 8+ attribute that generates a runtime-provided direct accessor with no reflection lookup on the hot path to a private field or method. Unlike reflection, there's no boxing, no late-bound dispatch, and no metadata lookup at call time. The JIT generates the same code as if the access were written directly.
using System.Runtime.CompilerServices;
public class BankAccount
{
private decimal _balance;
public BankAccount(decimal initial) => _balance = initial;
public decimal Balance => _balance;
}
// Declare the accessor -- must match the exact field name and type
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_balance")]
static extern ref decimal GetBalance(BankAccount account);
// Use it
var account = new BankAccount(100m);
GetBalance(account) = 200m;
Console.WriteLine(account.Balance); // 200
[UnsafeAccessor] is best suited for:
- Test helpers that need to set private state
- Framework code with deep performance requirements
- Cases where you know the type at compile time but the member is private
It's NOT a replacement for reflection when the type isn't known at compile time. In those cases, compiled delegates are your best option.
When Is Reflection "Fast Enough"?
Here's the practical framing:
Reflection with caching is fast enough when:
- The code runs at startup or at configuration time (cost is paid once)
- The code runs at a rate below a few thousand calls per second
- The overhead is dominated by I/O, network, or database work rather than CPU
If your code is running faster than that or the overhead can't be absorbed, you need a stronger strategy.
You need compiled delegates when:
- Processing thousands of objects per second in a tight loop
- Building a serializer, ORM, or data mapper that runs on every request
- Profiling has confirmed reflection as a measured bottleneck
For private member access on compile-time-known types, a different tool applies.
You need [UnsafeAccessor] when:
- The type is known at compile time
- You're accessing private members from a framework or test layer
- Zero overhead is required and AOT compatibility matters
And finally, when the problem is solvable at build time, don't solve it at runtime at all.
You should consider source generators when:
- The work can be done at compile time entirely
- AOT/trimming compatibility is a hard requirement
- You're willing to invest in a more complex setup
For dependency injection specifically, Automatic Service Discovery in C# with Needlr shows how a DI framework avoids runtime reflection by doing the discovery work at registration time. And Scrutor in C# - 3 Simple Tips to Level Up Dependency Injection covers assembly scanning patterns that pay the reflection cost once at startup.
How to Measure: BenchmarkDotNet Setup
Don't guess -- measure. BenchmarkDotNet is the standard tool for .NET microbenchmarks. Here's how to structure a benchmark comparing uncached reflection, cached reflection, and a compiled delegate for property reading:
<!-- Add to your benchmark project's .csproj -->
<PackageReference Include="BenchmarkDotNet" Version="0.14.*" />
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq.Expressions;
using System.Reflection;
public class Person
{
public string Name { get; set; } = "Alice";
public int Age { get; set; } = 30;
}
[MemoryDiagnoser]
[SimpleJob]
public class ReflectionBenchmarks
{
private readonly Person _person = new();
// Cached references -- built in GlobalSetup to isolate from the measurement
private PropertyInfo _cachedProp = null!;
private Func<Person, string> _compiledGetter = null!;
[GlobalSetup]
public void Setup()
{
_cachedProp = typeof(Person).GetProperty("Name")!;
var param = Expression.Parameter(typeof(Person), "p");
var prop = Expression.Property(param, _cachedProp);
_compiledGetter = Expression.Lambda<Func<Person, string>>(prop, param).Compile();
}
[Benchmark(Baseline = true)]
public string? DirectAccess() => _person.Name;
[Benchmark]
public string? UncachedReflection()
{
var prop = typeof(Person).GetProperty("Name");
return (string?)prop?.GetValue(_person);
}
[Benchmark]
public string? CachedReflection() => (string?)_cachedProp.GetValue(_person);
[Benchmark]
public string? CompiledDelegate() => _compiledGetter(_person);
}
// Entry point
// BenchmarkRunner.Run<ReflectionBenchmarks>();
A few notes on the structure:
[MemoryDiagnoser]shows allocation counts per operation -- crucial for spotting boxing[GlobalSetup]runs once before all iterations, so cached/compiled setup cost is excluded[Benchmark(Baseline = true)]on direct access gives you relative ratios automatically- Run in Release mode:
dotnet run -c Release
Run this benchmark in your specific environment and .NET version. Numbers vary significantly based on CPU architecture, CLR version, and workload. The goal is relative comparison, not absolute millisecond values.
Reflection performance is workload-dependent. Benchmark with your actual types and call patterns using BenchmarkDotNet before assuming any optimization is worthwhile. Numbers vary significantly across CPU architecture, .NET version, and workload -- relative comparisons between strategies matter more than absolute millisecond values.
FAQ
Reflection performance questions are some of the most nuanced in .NET development because the answer almost always depends on context. Here are answers to the questions that come up most often, grounded in how .NET 10 actually works.
Is reflection slow in .NET 10?
It depends on what part of reflection you mean and how you use it. Member lookup (GetProperty, GetMethod) is the expensive part and should be cached. GetValue/Invoke with cached PropertyInfo/MethodInfo is slower than direct access but often acceptable. Compiled delegates and [UnsafeAccessor] bring performance close to direct access. .NET runtime versions since .NET 6 have also progressively improved reflection internals.
What is the fastest way to access properties using reflection in C#?
The fastest approach with reflection's dynamic-dispatch semantics is a compiled delegate -- a Func<T, TProperty> built from an expression tree and cached statically. For private members on a compile-time-known type, [UnsafeAccessor] is faster still because the JIT can inline the access directly. Direct property access (no reflection) is always fastest when the type and member are known at compile time.
When should I use FrozenDictionary for reflection caching?
Use FrozenDictionary<Type, PropertyInfo[]> when your cache is built once at application startup and never modified after that point. The FrozenDictionary is optimized for read-heavy, no-mutation scenarios and can use a perfect-hash strategy internally for lower average lookup overhead than ConcurrentDictionary. It's part of System.Collections.Frozen in .NET 8+ and .NET 10.
Does reflection work with Native AOT in .NET 10?
Yes, but with important constraints. The Native AOT trimmer removes code that appears unreachable at publish time. Reflection-accessed members may be trimmed. You need to annotate reflection call sites with [DynamicallyAccessedMembers], use rd.xml trimmer roots, or replace the reflection with source generators or explicit code. The .NET 10 publish toolchain emits warnings for unannotated reflection to help you find the issues.
How do compiled expression trees compare to source generators for performance?
Both approaches eliminate the overhead of late-bound reflection at call time. Compiled expression trees are done at runtime (typically application startup) -- they're flexible and work with types discovered dynamically, but the compilation step takes time and the results live in memory. Source generators run at compile time and emit plain C# code into your build -- zero startup cost, AOT-safe, but require knowing the types at build time. Source generators are strictly better for AOT, trimming, and startup time. Compiled delegates are better for truly dynamic scenarios.
Can BenchmarkDotNet measure reflection overhead accurately?
Yes, BenchmarkDotNet is well-suited for this. Key requirements: run in Release mode (dotnet run -c Release), put expensive one-time setup in [GlobalSetup] rather than the benchmark method itself, use [MemoryDiagnoser] to see allocations, and run enough iterations for stable results (BenchmarkDotNet handles this automatically). Be careful not to benchmark the caching/compilation cost when you intend to benchmark the cached call cost.
What is UnsafeAccessor and when should I use it?
[UnsafeAccessor] is an attribute introduced in .NET 8 that generates a runtime-provided direct accessor with no reflection lookup on the hot path for private or internal members. Declare a static extern method with the attribute and the correct signature, and the JIT generates code as if you had written the access directly. It's ideal for accessing private state in test helpers, framework adapters, or performance-critical internal code. It requires knowing the target type at compile time and doesn't work for purely dynamic scenarios.
Conclusion
Reflection performance in .NET 10 comes down to a few clear principles. The lookup is expensive -- cache it. The invocation is slower than direct access -- compile it when it matters. Private member access now has a zero-overhead path through [UnsafeAccessor]. And for everything you can resolve at compile time, source generators eliminate the runtime cost entirely.
The practical guidance: start with cached PropertyInfo/MethodInfo in a ConcurrentDictionary. If your cache is read-only after startup, upgrade to FrozenDictionary. If profiling shows GetValue/Invoke as a bottleneck in a hot path, move to compiled delegates. If you're accessing specific private members from trusted code, use [UnsafeAccessor]. And if the feature can be built with a source generator instead of runtime reflection, that's usually the right long-term call.
Measure before you optimize. But when you do optimize, you now have a clear ladder of techniques to climb.

