Caching Reflection Metadata with FrozenDictionary in .NET 10
Reflection is one of the most powerful tools in .NET, and also one of the easiest to misuse. The performance tax of c# reflection caching (or rather, the cost of not caching) shows up subtly at first -- a few extra microseconds per request -- and then suddenly your profiler is pointing at GetProperties() in a hot path. The fix is always the same: cache the metadata. In .NET 10, FrozenDictionary<TKey, TValue> gives you the best read-only cache available in the BCL, and it's the right container for reflection metadata that's built once at startup and read millions of times afterward.
This article walks through the naive approach, the ConcurrentDictionary upgrade, and the FrozenDictionary payoff, with complete code examples for each step.
The Performance Tax of Uncached Reflection
Let's be precise about what "reflection overhead" means in practice.
When you call type.GetProperties(), the runtime doesn't return a pre-computed array. It walks the type's metadata tables, applies the binding flags you specified, filters the results, allocates a new PropertyInfo[], and returns it. Every call does all of that work -- even if you're asking for the same type's properties for the thousandth time.
GetProperties() is one of the heavier reflection calls. GetCustomAttribute<T>() on a PropertyInfo involves another scan. GetMethod() is similar. In a tight loop -- a serializer converting thousands of objects, a validator checking hundreds of requests per second -- this metadata scanning adds up to real latency.
The good news: the metadata is deterministic. The same type will always have the same properties and methods. You never need to call GetProperties() more than once per type per process lifetime. That's the entire insight behind reflection caching.
The Naive Cache: Static Dictionary
The simplest cache is a static Dictionary<Type, PropertyInfo[]>:
public static class ReflectionCache
{
private static readonly Dictionary<Type, PropertyInfo[]> _properties = new();
public static PropertyInfo[] GetProperties(Type type)
{
if (!_properties.TryGetValue(type, out PropertyInfo[]? props))
{
props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
_properties[type] = props;
}
return props;
}
}
This works in a single-threaded scenario and eliminates the repeated GetProperties() calls perfectly. But it has a critical problem: Dictionary<K, V> is not thread-safe for concurrent reads mixed with writes. If two threads both call GetProperties for a new type at the same time, you have a data race on _properties.
In .NET, a data race on Dictionary doesn't just return a wrong value -- it can throw InvalidOperationException or corrupt the dictionary's internal state. This naive implementation is a latent bug waiting to surface under load.
ConcurrentDictionary: Thread-Safe Caching
ConcurrentDictionary<TKey, TValue> is the standard fix for thread-safe caches built incrementally:
public static class ReflectionCache
{
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _properties = new();
public static PropertyInfo[] GetProperties(Type type)
=> _properties.GetOrAdd(type, static t =>
t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}
GetOrAdd is atomic from the perspective of the dictionary's consistency. Two threads can both call GetOrAdd for the same missing key, and both might compute the value -- but only one will "win" and be stored. The loser's computed value is discarded. This "at most once stored, possibly computed multiple times" behavior is acceptable for reflection metadata because computing it is idempotent and relatively cheap (compared to, say, opening a database connection).
This is the right pattern for caches that grow throughout the process lifetime as new types are encountered. It's used by virtually every reflection-heavy framework in the ecosystem: Newtonsoft.Json, System.Text.Json's internal metadata, AutoMapper, and many others.
When ConcurrentDictionary Is Not Enough
ConcurrentDictionary carries synchronization overhead optimized for concurrent reads and writes. Under contention (many threads accessing the same cache simultaneously), this overhead becomes a bottleneck. For a read-heavy cache where the set of types is known or fully populated at startup, you're paying locking overhead on every read even though the dictionary never changes.
That's the gap FrozenDictionary fills.
FrozenDictionary in .NET 10
FrozenDictionary<TKey, TValue> was introduced in .NET 8 as part of System.Collections.Frozen. In .NET 10 it continues to be the recommended container for read-only lookup tables that are constructed once and then queried frequently.
The key difference from ImmutableDictionary:
ImmutableDictionaryuses a balanced binary tree internally. Lookups are O(log n).FrozenDictionaryuses internal optimizations for immutable read-heavy lookup, often outperforming standardDictionary<>in pure read scenarios.- Neither supports mutation after construction.
For Type keys (which are reference types with stable GetHashCode() values), FrozenDictionary can outperform ConcurrentDictionary on reads because there's no lock acquisition at all -- the data structure is provably immutable, so reads need no synchronization.
Creating a FrozenDictionary:
using System.Collections.Frozen;
Dictionary<Type, PropertyInfo[]> source = new()
{
[typeof(Order)] = typeof(Order).GetProperties(BindingFlags.Public | BindingFlags.Instance),
[typeof(Customer)] = typeof(Customer).GetProperties(BindingFlags.Public | BindingFlags.Instance),
};
FrozenDictionary<Type, PropertyInfo[]> frozen = source.ToFrozenDictionary();
Once ToFrozenDictionary() is called, the resulting dictionary is immutable. Any attempt to modify it won't compile -- there are no mutation methods.
Building a Full ReflectionCache with FrozenDictionary
The challenge with FrozenDictionary is that it requires all entries upfront. For a startup-time registration pattern, this is straightforward:
using System.Collections.Frozen;
using System.Reflection;
public sealed class ReflectionCache
{
private readonly FrozenDictionary<Type, PropertyInfo[]> _properties;
private readonly FrozenDictionary<Type, ConstructorInfo?> _defaultConstructors;
private ReflectionCache(
FrozenDictionary<Type, PropertyInfo[]> properties,
FrozenDictionary<Type, ConstructorInfo?> defaultConstructors)
{
_properties = properties;
_defaultConstructors = defaultConstructors;
}
public static ReflectionCache BuildFor(IEnumerable<Type> types)
{
var typeList = types.ToList();
var properties = typeList
.ToDictionary(
t => t,
t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.ToFrozenDictionary();
var constructors = typeList
.ToDictionary(
t => t,
t => t.GetConstructor(Type.EmptyTypes))
.ToFrozenDictionary();
return new ReflectionCache(properties, constructors);
}
public PropertyInfo[] GetProperties(Type type)
{
if (_properties.TryGetValue(type, out PropertyInfo[]? props))
{
return props;
}
throw new InvalidOperationException(
$"Type '{type.FullName}' was not registered in the cache.");
}
public ConstructorInfo? GetDefaultConstructor(Type type)
{
if (_defaultConstructors.TryGetValue(type, out ConstructorInfo? ctor))
{
return ctor;
}
throw new InvalidOperationException(
$"Type '{type.FullName}' was not registered in the cache.");
}
}
Usage at startup:
// At application startup -- scan the assembly once, build the cache, freeze it
ReflectionCache cache = ReflectionCache.BuildFor(
Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract));
// Register in DI as singleton
services.AddSingleton(cache);
After BuildFor returns, all reflection scanning is done. The frozen dictionary serves lookups for the rest of the application lifetime with no locks and no metadata scanning.
Extending to MethodInfo
The same pattern applies to MethodInfo. Here's a cache for methods tagged with a custom attribute:
public sealed class CommandMethodCache
{
private readonly FrozenDictionary<string, MethodInfo> _commands;
private CommandMethodCache(FrozenDictionary<string, MethodInfo> commands)
{
_commands = commands;
}
public static CommandMethodCache BuildFor(object handler)
{
var commands = handler.GetType()
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.SelectMany(m => m.GetCustomAttributes<CommandAttribute>()
.Select(attr => (attr.Name, Method: m)))
.ToDictionary(x => x.Name, x => x.Method, StringComparer.OrdinalIgnoreCase)
.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
return new CommandMethodCache(commands);
}
public bool TryGetCommand(string name, out MethodInfo? method)
=> _commands.TryGetValue(name, out method);
}
Using Lazy<T> for Single-Initialization Caches
When you can't enumerate all types at startup -- for example, when loading plugins from external assemblies -- you need a pattern that initializes safely once and then serves reads without locking.
Avoid rolling your own lock-free cache initialization with volatile -- the JIT's memory model makes this error-prone. Use Lazy<T> or ConcurrentDictionary.GetOrAdd instead.
If your full type set can be discovered in a single sweep (e.g., at app startup after all assemblies are loaded), Lazy<FrozenDictionary<...>> is the cleanest approach:
private static readonly Lazy<FrozenDictionary<Type, PropertyInfo[]>> _cache =
new(() => DiscoverAllTypes()
.ToFrozenDictionary(
t => t,
t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)));
public static PropertyInfo[] GetProperties(Type type)
=> _cache.Value[type];
Lazy<T> guarantees thread-safe single initialization with no manual locking. The FrozenDictionary is built exactly once on first access, and all subsequent reads are lock-free.
For caches that must grow at runtime (e.g., plugin systems that load assemblies dynamically after startup), use ConcurrentDictionary throughout the process lifetime, or use a registration phase with ConcurrentDictionary followed by a one-time snapshot to FrozenDictionary once the type set stabilizes.
When to Use FrozenDictionary vs ImmutableDictionary vs ConcurrentDictionary
All three are valid choices depending on the scenario. Here's the decision matrix:
| Container | Mutability | Thread safety | Read performance | Use when |
|---|---|---|---|---|
Dictionary<K,V> |
Mutable | None | Fastest | Single-threaded, no sharing |
ConcurrentDictionary<K,V> |
Mutable | Full | Good | Cache grows at runtime, concurrent access |
ImmutableDictionary<K,V> |
Immutable | Full | O(log n) | Need immutable snapshots, functional style |
FrozenDictionary<K,V> |
Immutable | Full | Best | Built once at startup, read many times |
For reflection metadata, FrozenDictionary wins when the type set is known at startup. ConcurrentDictionary wins when types arrive at runtime (e.g., lazy loading, plugin discovery). ImmutableDictionary is rarely the right choice for a cache -- its tree-based lookup is slower than both alternatives.
Caching Compiled Delegates Alongside Metadata
PropertyInfo and MethodInfo are the metadata. The real performance win comes from combining metadata caching with compiled delegate caching -- so you don't call PropertyInfo.GetValue() (which involves reflection on every call) but instead invoke a compiled getter.
public sealed class PropertyAccessorCache
{
private readonly FrozenDictionary<(Type Type, string Property), Func<object, object?>> _getters;
private PropertyAccessorCache(
FrozenDictionary<(Type, string), Func<object, object?>> getters)
{
_getters = getters;
}
public static PropertyAccessorCache BuildFor(IEnumerable<Type> types)
{
var getters = new Dictionary<(Type, string), Func<object, object?>>();
foreach (Type type in types)
{
foreach (PropertyInfo prop in type.GetProperties(
BindingFlags.Public | BindingFlags.Instance))
{
if (prop.CanRead)
{
getters[(type, prop.Name)] = BuildGetter(type, prop);
}
}
}
return new PropertyAccessorCache(getters.ToFrozenDictionary());
}
public Func<object, object?>? GetGetter(Type type, string propertyName)
{
_getters.TryGetValue((type, propertyName), out var getter);
return getter;
}
private static Func<object, object?> BuildGetter(Type type, PropertyInfo prop)
{
// Build: (object instance) => (object)((MyType)instance).PropertyName
ParameterExpression instanceParam = Expression.Parameter(typeof(object), "instance");
MemberExpression propertyAccess = Expression.Property(
Expression.Convert(instanceParam, type),
prop);
Expression body = Expression.Convert(propertyAccess, typeof(object));
return Expression.Lambda<Func<object, object?>>(body, instanceParam).Compile();
}
}
This cache stores both the metadata (implicitly, via the (Type, string) key) and the compiled getter delegate. Reading a property now means a single dictionary lookup and a delegate invocation -- no reflection at the call site.
The compiled expression pattern is explored in detail in ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation, and the service registration patterns that benefit from this are covered in IServiceCollection in C# -- Complete Guide.
For a look at how assembly scanning integrates with DI containers to do this work automatically, see Automatic Service Discovery in C# with Needlr and Automatic Dependency Injection in C#: The Complete Guide to Needlr.
Benchmarking the Cache Strategies
Set up a BenchmarkDotNet benchmark to measure the difference across approaches:
using BenchmarkDotNet.Attributes;
using System.Collections.Frozen;
[MemoryDiagnoser]
public class ReflectionCacheBenchmarks
{
private static readonly Type TargetType = typeof(SampleModel);
// No cache
[Benchmark(Baseline = true)]
public PropertyInfo[] UncachedGetProperties()
=> TargetType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// ConcurrentDictionary cache
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _concurrent = new();
[Benchmark]
public PropertyInfo[] ConcurrentDictionaryCache()
=> _concurrent.GetOrAdd(TargetType,
static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
// FrozenDictionary cache
private static readonly FrozenDictionary<Type, PropertyInfo[]> _frozen =
new Dictionary<Type, PropertyInfo[]>
{
[typeof(SampleModel)] = typeof(SampleModel)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
}.ToFrozenDictionary();
[Benchmark]
public PropertyInfo[] FrozenDictionaryCache()
=> _frozen[TargetType];
}
Run this in Release mode with BenchmarkRunner.Run<ReflectionCacheBenchmarks>(). The [MemoryDiagnoser] attribute will show allocation counts alongside timing -- important because GetProperties() allocates a new array on every call.
FAQ
Why is reflection slow in the first place?
Reflection involves runtime metadata scanning: the CLR walks the type's metadata tables to find members that match the requested binding flags, allocates arrays and wrapper objects (like PropertyInfo), and applies any requested filters. None of this can be inlined or optimized away by the JIT because the types and members aren't known at compile time.
Is FrozenDictionary available in .NET 8 and 9, or only .NET 10?
FrozenDictionary<TKey, TValue> was introduced in .NET 8 as part of the System.Collections.Frozen namespace. It is available in .NET 8, 9, and 10. In .NET 10, the implementation continues to be refined for better performance on specific key types.
When should I use ConcurrentDictionary instead of FrozenDictionary?
Use ConcurrentDictionary when your cache needs to grow after initialization -- for example, when new types arrive at runtime via plugin loading, lazy deserialization, or user-triggered actions. Use FrozenDictionary when you can enumerate all types upfront at startup and the cache will never change after that point.
Does FrozenDictionary have better read performance than Dictionary?
For large dictionaries with reference-type keys, FrozenDictionary can outperform Dictionary due to its internal read-heavy optimizations. For small dictionaries (a handful of entries), the difference is negligible. The real win over ConcurrentDictionary is the complete elimination of locking overhead on reads.
Can I cache MethodInfo and ConstructorInfo the same way as PropertyInfo?
Yes. MethodInfo, ConstructorInfo, FieldInfo, and EventInfo are all MemberInfo subtypes and have the same caching story. Cache them keyed on (Type, string) for method name, or (Type, Type[]) for overload resolution. The same FrozenDictionary approach applies.
What's the best way to handle types added at runtime after the FrozenDictionary is built?
For caches that must grow at runtime, use ConcurrentDictionary during the growth phase. Once the type set is stable, snapshot it to FrozenDictionary with .ToFrozenDictionary(). After the snapshot, reads are lock-free. For caches where all types are known at startup, Lazy<FrozenDictionary<...>> handles single-initialization safely with no manual locking.
How does reflection caching relate to source generators?
Both solve the same problem -- reflection overhead -- but at different layers. Reflection caching reduces the cost of runtime reflection by paying it once instead of many times. Source generators eliminate runtime reflection entirely by generating type-aware code at compile time. If your types are source-available and you want zero runtime reflection overhead, source generators are the ultimate solution. See Source Generation vs Reflection in Needlr for a concrete comparison.
Conclusion
Uncached reflection is the single most common reflection performance mistake in .NET codebases. The fix is mechanical: call GetProperties() (or GetMethods(), GetConstructors(), etc.) once per type, store the result, and return the cached value on every subsequent call.
The right container for that cache depends on your access pattern. ConcurrentDictionary is the safe default for caches that grow at runtime. FrozenDictionary is the upgrade when your type set is known at startup and you want lock-free reads optimized for immutable lookup.
Pair the metadata cache with compiled expression delegates (for property getters and setters), and you've eliminated reflection overhead from both the metadata lookup and the value access paths. That's the combination that powers high-performance serializers, DI containers, and ORMs in .NET 10 -- and now it can power yours too.

