# 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.

---

The right collection here is about how often you read versus build. The full benchmark setup is on [Patreon](https://www.patreon.com/CodingDroplets), ready to run against your own data.

---

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:

1. The collection is populated once - at startup, from configuration, or from a seeded database query.
2. The collection is read many more times than it is created. Lookup frequency is high; creation frequency is near-zero.
3. 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](https://aspnetcoreapi.codingdroplets.com/) 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.

---

---

## 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.

* * *

## About the Author

**Celin Daniel** is Co-founder of Coding Droplets with 13+ years of hands-on experience building, shipping, and operating .NET and ASP.NET Core systems in production. The guidance here comes from real projects and production incidents, not theory.

- Website: [codingdroplets.com](https://codingdroplets.com/)
- GitHub: [github.com/codingdroplets](http://github.com/codingdroplets/)
- YouTube: [youtube.com/@CodingDroplets](https://www.youtube.com/@CodingDroplets)
