DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

The Freeze Fee: What FrozenDictionary Charges and When It Pays

There's a static lookup table in every service I've ever worked on. A thousand-ish feature flags, or route metadata, or country codes — built once at startup, read on every single request, never touched again. For years my fingers have typed ToImmutableDictionary() for those. Immutable, read-only, sounds correct. Last night I finally asked the question I should've asked in 2023: is "immutable" actually the fast option here, or did I just like the word?

So I lined up the three containers .NET gives you for a build-once, read-forever table and made each of them do ten million lookups.

The contenders

Same 1,000 feature-flag style keys (flags:checkout:variant-0042), same values, default ordinal comparer for all three. No tricks, just the three ways you'd naturally write it:

var dictionary = new Dictionary<string, string>(pairs);
var immutable  = pairs.ToImmutableDictionary();
var frozen     = pairs.ToFrozenDictionary();   // System.Collections.Frozen, .NET 8+
Enter fullscreen mode Exit fullscreen mode

The lookup loop cycles through 4,096 pre-shuffled keys so the branch predictor can't memorize the sequence, and sums value lengths so the JIT can't delete the work:

long checksum = 0;
for (var i = 0; i < LookupOps; i++)
    if (frozen.TryGetValue(hitQueries[i & QueryMask], out var v))
        checksum += v.Length;
Enter fullscreen mode Exit fullscreen mode

Ten million lookups per round, best of five rounds, .NET 10 Release build in a small Linux container. Not a lab — I care about the ratios, not the absolute nanoseconds.

The part that made me wince

== lookups, all keys present ==
Dictionary          :   18.0 ns/lookup
ImmutableDictionary :   86.7 ns/lookup
FrozenDictionary    :   11.9 ns/lookup
Enter fullscreen mode Exit fullscreen mode

FrozenDictionary beats plain Dictionary by about 1.5x. Nice. But look at the middle line. ImmutableDictionary — the thing I've been using specifically for read-only data — is over 7x slower than frozen, and nearly 5x slower than the boring mutable Dictionary it was supposedly an upgrade from.

The wince is because this isn't a bug, it's a design I'd never bothered to understand. ImmutableDictionary isn't a read-optimized dictionary that happens to be immutable. It's a hash trie — a tree — built so that "copy this dictionary with one extra entry" is cheap and doesn't disturb the original. Every lookup walks pointers through tree nodes. You're paying, on every read, for a copy-with-changes capability my startup table will never use once.

FrozenDictionary is the opposite trade. ToFrozenDictionary() takes its time inspecting your actual keys — for strings it can find the few characters that distinguish the whole key set and hash only those — then picks a specialized internal layout for exactly that data. All the cleverness happens once, at construction. Which raises the obvious question: what does that cost?

The freeze fee

== construction, 1,000 entries, avg of 2,000 builds ==
new Dictionary(pairs)  :     21.8 µs/build
ToImmutableDictionary():    211.4 µs/build
ToFrozenDictionary()   :    166.8 µs/build
Enter fullscreen mode Exit fullscreen mode

There it is. Freezing 1,000 entries costs roughly 8x building a plain Dictionary — about 145 µs of surcharge in my runs. That's the fee, and it's real: build a FrozenDictionary per request and you've made everything worse for no reason.

But here's the twist I didn't see coming: ToImmutableDictionary() costs more than the freeze. For a build-once table, ImmutableDictionary loses on both ends — more expensive to construct and 7x slower to read. There's no scenario in that use case where it's the right pick. It still has a real job — when you genuinely need cheap non-destructive updates, like versioned snapshots of a changing set — but "this data never changes" was never that job. I'd been reading "immutable" as "optimized for reading" for two years. It means "optimized for copying."

My little program also computed the break-even against Dictionary: the ~145 µs surcharge divided by the ~6 ns saved per hit comes out to roughly 24,000 lookups. A singleton flag table consulted a few times per request clears that in the first minute of traffic, then collects the discount forever.

Where the win shrinks

Honesty section. Lookups for keys that don't exist barely moved: 14.0 ns for Dictionary vs 11.8 ns frozen. Dictionary already rejects most misses on a hash mismatch without ever comparing strings, so there's less waste for frozen to reclaim. I expected miss-heavy workloads to be the showcase and they're the weakest result on the table. If your table mostly answers "no", the upgrade is real but small.

And the usual caveats: these are my ratios on one container with 1,000 string keys. Different key shapes get different specialized strategies, tiny tables are fast in anything, and none of this matters on a dictionary you rebuild often — per-request construction hands the 8x fee back with interest.

My rule after this experiment, stated as the opinion it is: any static readonly Dictionary that nothing ever writes to is a FrozenDictionary wearing the wrong coat. And any ToImmutableDictionary() on data that never changes is a bug report I'm filing against my own muscle memory.

Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/011-frozen-dictionary-lookup

Got a ToImmutableDictionary() sitting on startup data somewhere? Go time it — and tell me in the comments if your ratio beats my 7x.

— still benchmarking things nobody asked me to, and serving the results cold

Top comments (0)