FrozenDictionary vs ImmutableDictionary vs Dictionary in .NET: Which Should Your Team Use in High-Traffic APIs?

When your ASP.NET Core API handles thousands of lookups per second against a static in-memory table โ permission codes, country maps, error message registries โ the collection type you choose is not an implementation detail. It is a performance decision. In .NET 8, Microsoft introduced FrozenDictionary<TKey, TValue> and FrozenSet<T> specifically for this workload: immutable, read-optimised, and measurably faster than Dictionary<TKey, TValue> for frequent lookups. But developers frequently confuse it with ImmutableDictionary<TKey, TValue>, which exists for a different purpose entirely.
๐ Want implementation-ready .NET source code you can drop straight into your project? Join Coding Droplets on Patreon for exclusive tutorials, premium code samples, and early access to new content. ๐ https://www.patreon.com/CodingDroplets
This article breaks down the three options โ Dictionary<TKey, TValue>, ImmutableDictionary<TKey, TValue>, and FrozenDictionary<TKey, TValue> โ so your team can make the right call based on access pattern, mutation requirements, and performance budget.
What Problem Does Each Collection Solve?
Before diving into comparisons, it is worth anchoring each type to its intended use case.
Dictionary<TKey, TValue> is the workhorse. It supports reads, writes, and updates across its lifetime. It is the right default for collections that change โ session stores, in-flight request state, dynamic caches.
ImmutableDictionary<TKey, TValue> (from System.Collections.Immutable) is designed for functional-style programming where each "modification" produces a new dictionary instance, leaving the original intact. It is optimised for safe structural sharing, not for raw lookup speed.
FrozenDictionary<TKey, TValue> (from System.Collections.Frozen, introduced in .NET 8) is optimised exclusively for the read path. Once created, it cannot be modified. Its creation is expensive. Its lookup performance is exceptional โ up to 2.5xโ3.5x faster than Dictionary per Microsoft's own benchmarks for the ASP.NET Core 8 performance improvements.
Side-by-Side Comparison
| Characteristic | Dictionary<TKey, TValue> |
ImmutableDictionary<TKey, TValue> |
FrozenDictionary<TKey, TValue> |
|---|---|---|---|
| Mutable after creation | โ Yes | โ No (creates new on change) | โ No |
| Read performance | Baseline | Slower than Dictionary | 2.5xโ3.5x faster than Dictionary |
| Creation cost | Low | Medium | High |
| Thread-safe for reads | โ Not by default | โ Yes | โ Yes |
| Memory layout | Hash table | Sorted tree / trie | Pre-computed, JIT-friendly layout |
| Supports structural sharing | โ | โ Yes | โ |
| Namespace | System.Collections.Generic |
System.Collections.Immutable |
System.Collections.Frozen |
| Available since | .NET 1.0 | .NET 4.5 | .NET 8 |
| Ideal for | General-purpose | Functional updates, undo history | Static lookup tables, high-read workloads |
When Should You Use FrozenDictionary?
FrozenDictionary earns its place when all three conditions hold:
- The collection is populated once โ at startup, from configuration, or from a seeded database query.
- The collection is read many more times than it is created. Lookup frequency is high; creation frequency is near-zero.
- Read latency on the hot path matters โ middleware, routing, permission checks, or per-request DI resolution.
Real production scenarios where FrozenDictionary fits well include mapping HTTP status codes to error messages, storing country/currency/locale lookup tables loaded at startup, caching permission-to-resource mappings resolved from configuration, and pre-loading route constraint tables or feature flag registries.
The .NET runtime itself uses FrozenDictionary internally in ASP.NET Core 8+ for exactly these patterns โ routing tables and request header lookups among them.
When Should You Use ImmutableDictionary?
ImmutableDictionary is the right tool when the semantics of your operation require that the previous state of the collection is preserved after a change. This is not a performance tool. It is a correctness tool.
Appropriate uses include implementing an undo/redo stack where each state transition creates a new snapshot, sharing a base configuration dictionary across threads while allowing per-request derivations, and functional pipelines where transformations must not affect the source data.
The critical distinction: ImmutableDictionary lets you "add" a key and get a new dictionary back with the old one intact. FrozenDictionary does not support this at all. If you need immutability-with-update, ImmutableDictionary is correct. If you need read-only performance, FrozenDictionary is correct.
When Should You Keep Using Dictionary?
Dictionary<TKey, TValue> remains the right choice for the majority of use cases โ any collection where entries are added, removed, or updated during normal operation. Caching layers where entries expire or are evicted, session-scoped state, dynamic registries populated per-request, and integration maps built up during message processing all belong in Dictionary.
The instinct to replace Dictionary with FrozenDictionary everywhere is a mistake. FrozenDictionary's creation is slow by design โ it pre-computes its internal structure for optimal lookups. Creating one in a hot path or on a per-request basis will hurt performance, not help it.
Is FrozenDictionary Thread-Safe?
FrozenDictionary is inherently safe for concurrent reads from multiple threads without locks, since it cannot be modified after creation. This makes it particularly well-suited for use as a singleton-scoped dependency in ASP.NET Core, where a DI-resolved service might be accessed by hundreds of concurrent requests.
Dictionary<TKey, TValue> is not thread-safe for concurrent reads during writes. If you need a mutable, thread-safe dictionary, use ConcurrentDictionary<TKey, TValue>. Do not confuse thread safety for reads-only (what FrozenDictionary provides) with thread safety during mutation (what ConcurrentDictionary provides).
Does FrozenDictionary Work with Dependency Injection?
Yes, and this is one of its cleanest use cases. Register a FrozenDictionary as a singleton via IServiceCollection, populate it at startup from configuration or a one-time database call, and inject it wherever you need fast lookups. Because it is sealed, immutable, and thread-safe, it is ideal for the singleton lifetime โ no locking, no defensive copying, no race conditions.
This fits naturally into the Options pattern as well. You can hydrate a FrozenDictionary inside IOptions<T> during Configure<T> registration, making it part of your application's strongly-typed configuration graph.
How Does FrozenDictionary Achieve Its Performance?
The internal structure of FrozenDictionary is not a standard hash table. During creation, it analyses the key set and selects an algorithm โ ranging from a small-set linear scan optimised for very small dictionaries to a specialised hash strategy tuned to the actual keys present. The layout is pre-computed and cache-friendly, which is why the JIT can produce tighter assembly for lookups.
This is fundamentally different from Dictionary, which must support mutation and therefore cannot commit to a fixed internal structure. The trade-off is explicit: FrozenDictionary pays its cost at creation time to eliminate overhead at every subsequent lookup.
Real-World Trade-Offs
Creation Cost at Startup
If your startup path builds several large FrozenDictionary instances from external data sources โ databases, config files โ that creation cost is real. In most APIs it is paid once on startup and then amortised across millions of requests. Monitor startup time, particularly in containerised deployments where fast cold-start matters.
No in-Place Updates
FrozenDictionary does not support any update path. If your lookup table needs to refresh periodically โ for example, updating country lists weekly โ you need a strategy: either rebuild and replace the singleton via IOptionsMonitor<T>, use an atomic reference swap, or re-evaluate whether FrozenDictionary is the right tool for data that changes.
For data that changes infrequently but must occasionally refresh, consider a volatile or Interlocked.Exchange-guarded reference to the current FrozenDictionary instance rather than locking.
Compatibility
FrozenDictionary<TKey, TValue> requires .NET 8 or later. If your project still targets .NET 6 or .NET Framework, it is not available. In those cases, Dictionary<TKey, TValue> with a ConcurrentDictionary wrapper or a static ReadOnlyDictionary<TKey, TValue> are the appropriate alternatives.
Recommendation
For teams running .NET 8 or later, the decision matrix is straightforward.
If the collection changes after creation, use Dictionary<TKey, TValue>.
If the collection must support functional-style copy-on-write updates, use ImmutableDictionary<TKey, TValue>.
If the collection is populated once and read at high frequency โ particularly in ASP.NET Core middleware, DI-resolved services, routing, or per-request lookups โ use FrozenDictionary<TKey, TValue>. It is the right tool, not a micro-optimisation curiosity.
The performance delta matters at scale. On a service handling ten thousand requests per second, each of which hits a permission lookup table, shaving two microseconds per lookup compounds across the life of the deployment. That is the kind of return FrozenDictionary delivers โ not heroic, but consistent and free.
Chapter 9 of the ASP.NET Core Web API: Zero to Production course covers caching strategy in depth โ including how to structure singleton-scoped lookup tables alongside distributed caching, output caching, and HybridCache so all three work together without competing.
โ Prefer a one-time tip? Buy us a coffee โ every bit helps keep the content coming!
FAQ
What is the difference between FrozenDictionary and ImmutableDictionary in .NET?
FrozenDictionary is optimised for fast read performance and cannot be updated at all once created. ImmutableDictionary supports functional-style copy-on-write updates โ each "change" returns a new dictionary with the modification applied. Use FrozenDictionary for static lookup tables where you need maximum read throughput. Use ImmutableDictionary when you need to share dictionary state across threads while still deriving updated versions from it.
Is FrozenDictionary thread-safe in ASP.NET Core?
Yes. Because FrozenDictionary is immutable, all reads are inherently safe from any number of concurrent threads. It is safe to register as a singleton in ASP.NET Core's DI container and inject into services accessed by concurrent requests.
When should I not use FrozenDictionary?
Avoid FrozenDictionary when your collection needs to be updated after creation. Its creation cost is deliberately high because it pre-computes its internal structure for fast lookups. Creating it on a hot path or per-request will cause performance degradation. Also avoid it if you are targeting .NET 6 or earlier โ it requires .NET 8 or later.
How much faster is FrozenDictionary compared to Dictionary?
Per Microsoft's ASP.NET Core 8 performance benchmarks, FrozenDictionary TryGetValue lookups are 2.5x to 3.5x faster than Dictionary<TKey, TValue> for the same operation. The exact gain depends on the collection size and key type. Smaller collections with string or integer keys typically see the largest improvement.
Can I use FrozenDictionary with ASP.NET Core Dependency Injection?
Yes. Register a FrozenDictionary<TKey, TValue> as a singleton during application startup and inject it into your services. Because it is immutable and thread-safe, it is a natural fit for the singleton lifetime โ no locking needed, no risk of shared mutable state.
Does FrozenDictionary support LINQ queries?
Yes. FrozenDictionary<TKey, TValue> implements IEnumerable<KeyValuePair<TKey, TValue>>, so all standard LINQ operations work on it. However, mutation operators like ToDictionary will produce a regular Dictionary or a new FrozenDictionary depending on the method you chain.
What happens if I need to refresh a FrozenDictionary at runtime?
Since FrozenDictionary cannot be modified, you must replace the entire instance. Common strategies include wrapping the reference in a volatile field and performing an atomic swap with Interlocked.Exchange, or using ASP.NET Core's IOptionsMonitor<T> to trigger re-creation when configuration changes. The cost is a single creation per refresh, not per lookup.




