7.8 MB of Keys Thrown Away
A profiler trace exposed a hidden cost: a request handler was allocating 7.8 MB of strings just to look up tokens in a dictionary. The logic was correct, but the implementation was wasteful. Every token sliced from a large blob became a temporary string, used once, then discarded.
The culprit was Substring in a hot loop. For 200,000 tokens, this pattern allocated 7,812 KB and took 23.4 ms. The fix uses .NET 9's GetAlternateLookup with ReadOnlySpan, cutting allocations to zero and time to 14.9 ms—a 1.57x speedup.
The Problem: Substring in a Loop
Here's the typical code:
int start = 0;
for (int i = 0; i <= input.Length; i++)
{
if (i == input.Length || input[i] == ' ')
{
string token = input.Substring(start, i - start); // allocates
if (table.TryGetValue(token, out long w)) sum += w;
start = i + 1;
}
}
Substring creates a new string for each token. With 200,000 tokens, that's 200,000 allocations. Most tokens are not retained; they die immediately after the lookup. The garbage collector later sweeps them up, adding latency to other requests.
The profiler showed 7.8 MB allocated per call. On a busy endpoint, that's 7.5 MB of garbage per request. This hidden cost is easy to miss in code review because Substring reads naturally.
The Solution: GetAlternateLookup
Since .NET 9, Dictionary supports alternate lookup keys. For a string-keyed dictionary, you can use ReadOnlySpan as the key type. This allows you to look up spans directly without allocating a string.
var lookup = table.GetAlternateLookup>();
ReadOnlySpan span = input;
int start = 0;
for (int i = 0; i <= span.Length; i++)
{
if (i == span.Length || span[i] == ' ')
{
ReadOnlySpan token = span.Slice(start, i - start); // no alloc
if (lookup.TryGetValue(token, out long w)) sum += w;
start = i + 1;
}
}
Slice doesn't copy; it creates a view over the original string. The comparer hashes and compares the span against stored keys without building a temporary string. The dictionary entries remain the same—you just get a second way to access them.
The results: identical sum (6,963,210), zero bytes allocated, and 14.9 ms median time. The allocation ratio is about 200,000x, and time improves by 1.57x.
The Catch: Comparer Requirements
GetAlternateLookup only works if the dictionary's comparer implements IAlternateEqualityComparer, string>. The good news: StringComparer.Ordinal, StringComparer.OrdinalIgnoreCase, and the default comparer for new Dictionary() all qualify. Custom comparers will throw an exception at runtime, not compile time. So you need a test to catch that.
When to Use This
This is a hot-path tool, not a default. If you're looking up a few keys, or the strings already exist as string objects, adding this ceremony is unnecessary. It shines when you're carving keys out of a larger buffer—parsers, tokenizers, CSV or header scanners, log processors—where the substring is pure waste.
The author suggests grepping your code for Substring( followed by TryGetValue on the next line. This pattern is more common than you think.
Benchmark Details
The benchmark used .NET 10, Release build, small Linux container. Input was a 1.5 MB string with 200,000 space-separated tokens, 70% of which matched a 5,000-entry dictionary. Timings are median of 9 rounds after warmup, allocations from GC.GetAllocatedBytesForCurrentThread. The same result confirms no behavior change.
Actionable Takeaways
- Profile your hot loops: allocation spikes often hide in innocuous
Substringcalls. - Use
GetAlternateLookupwhen slicing tokens from a buffer for dictionary lookups. - Test custom comparers: they may not support alternate lookup.
- Consider this pattern for any code that parses delimited text.
Full Sample
A runnable sample is available on GitHub: dev-to-code-samples.
Have you found a spot where your key was already in a buffer you owned? Try this technique and measure the difference.



