TL;DR: Replacing a LINQ predicate in the Azure Service Bus receiver removed a repeated delegate allocation. Going further helped some inputs and hurt others. The useful part of that work was learning what the query became and which costs mattered to the SDK, not finding a reason to stop using LINQ.
One of the Azure Service Bus changes I worked on started with a short LINQ expression in the receiver. It was readable, did what it needed to do, and sat on a path that completed messages. The interesting cost only became obvious when I looked at the generated code.
This is the kind of small change I had in mind in the first post. A few lines can be worth investigating when they run for every message. That does not make the same change worthwhile in setup code, and it certainly does not make Any something to avoid.
The source looked harmless
The code was in the Azure Service Bus Advanced Message Queuing Protocol (AMQP) receiver. It accepted message lock tokens as strings, converted them to Guid values, and checked whether any belonged to a message received through the request-response link. Those messages have to be settled through that link instead of the regular receive link. I have left both settlement paths out of the sample so we can focus on the lookup.
private readonly ConcurrentExpiringSet<Guid> _lockedMessages = new();
private Task CompleteInternalAsync(IEnumerable<string> lockTokens)
{
Guid[] lockTokenGuids = lockTokens
.Select(token => new Guid(token))
.ToArray();
if (lockTokenGuids.Any(lockToken =>
_lockedMessages.Contains(lockToken)))
{
// Settle through the request-response link
return Task.CompletedTask;
}
// Settle through the receive link
return Task.CompletedTask;
}
The public API used IEnumerable<string> deliberately because an SDK should accept a broad range of callers. That choice also hides the concrete collection type from this method.
The delegate was the interesting part
The predicate passed to Any accessed an instance field, which meant the delegate needed a reference to the current object. In the runtime targeted by the original contribution, the compiler created that instance-bound Func<Guid, bool> each time the method ran.
// Decompiled code, with unrelated generated code omitted
private Task CompleteInternalAsync(IEnumerable<string> lockTokens)
{
Enumerable.Any(
Enumerable.ToArray(
Enumerable.Select(
lockTokens,
<>c.<>9__2_0 ??
(<>c.<>9__2_0 = new Func<string, Guid>(
<>c.<>9.<CompleteInternalAsync>b__2_0)))),
new Func<Guid, bool>(<CompleteInternalAsync>b__2_1));
return Task.CompletedTask;
}
[CompilerGenerated]
private bool <CompleteInternalAsync>b__2_1(Guid lockToken)
{
return Enumerable.Contains(_lockedMessages, lockToken);
}
The generated Select delegate uses the ?? assignment to cache one instance. The predicate passed to Any instead contains new Func<Guid, bool> directly, so every call creates another delegate.
That difference is easy to miss when reading the original expression. Looking at the decompiled version helped me connect the lambda in my source to an object created on every call. This translation from convenient C# syntax into simpler constructs is called lowering. It became a useful way to check what I thought the code was doing.
My first change left the conversion alone and replaced only the predicate.
Guid[] lockTokenGuids = lockTokens
.Select(token => new Guid(token))
.ToArray();
foreach (Guid lockTokenGuid in lockTokenGuids)
{
if (_lockedMessages.Contains(lockTokenGuid))
{
return Task.CompletedTask;
}
}
The loop removed that delegate allocation and made the early exit explicit. The benchmarks covered arrays, lists, sets, lazy enumerables, and several input sizes. In that comparison, every shape got faster and allocated less. A single-element array went from 129 nanoseconds and 240 bytes to 93 nanoseconds and 144 bytes. That was a useful result for a change small enough to explain in review.

I would remeasure this on a newer runtime
I would not take those numbers as a reason to make the same change in a .NET 10 application. The original contribution targeted an older runtime, and LINQ has kept improving since then. The source can stay the same while the work underneath it changes.
LINQ uses specialized iterator types for combinations such as arrays with Select, lists with Skip and Take, or a Where followed by Select. Those types preserve information about the source and earlier operators, allowing later operations to fuse work or skip it entirely.
The .NET 10 performance article has a good example: source.OrderBy(...).Contains(value) can search the underlying source without sorting it. Ordering cannot change whether a value exists. Other Contains specializations avoid reversing, distinct processing, or extra iterator layers. Several of those chains are dramatically faster than they were on .NET 9.
That is why I want the target framework beside the benchmark result. Otherwise I may end up maintaining a hand-written replacement for work the runtime no longer does.
The next change needed more context
There was still more collection work in the receiver. The method accepted IEnumerable<string>, which hid information about the input. A List<T> enumerated directly can use its struct enumerator. The same list exposed as IEnumerable<T> may involve interface dispatch or boxing, depending on the operation and runtime.
Narrowing the public API was not the answer. The SDK needed to keep accepting inputs that were not materialized collections. Internally, though, knowing the count would let the code allocate the final array once and combine the conversion and lookup.
The second version kept any IReadOnlyCollection<string> as-is and copied everything else into an array. That gave the internal method a count, but it also introduced a cost for callers passing a lazy sequence. Including that input in the benchmark mattered.
public Task CompleteAsync(IEnumerable<string> lockTokens)
{
IReadOnlyCollection<string> readOnlyCollection = lockTokens switch
{
IReadOnlyCollection<string> asReadOnlyCollection => asReadOnlyCollection,
_ => lockTokens.ToArray(),
};
return CompleteInternalAsync(readOnlyCollection);
}
private Task CompleteInternalAsync(IReadOnlyCollection<string> lockTokens)
{
int count = lockTokens.Count;
Guid[] lockTokenGuids = count == 0 ? Array.Empty<Guid>() : new Guid[count];
int index = 0;
foreach (string token in lockTokens)
{
Guid tokenGuid = new Guid(token);
lockTokenGuids[index++] = tokenGuid;
if (_lockedMessages.Contains(tokenGuid))
{
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
Sets benefited most, and lists improved modestly. A four-element HashSet<string> went from 225 nanoseconds and 280 bytes to 80 nanoseconds and 128 bytes. An empty lazy enumerable went from 20 to 31 nanoseconds because it now had to be copied into an array first. Four-element arrays also measured slower, likely because the loop now enumerates them through an interface instead of indexing the array directly.

This was no longer the straightforward win of the first change. Production callers overwhelmingly passed materialized collections, which made the trade-off acceptable for this SDK. The benchmark showed me where the costs moved. Knowledge of the callers was what made the decision possible.
What I look for now
After working through changes like this, I read a hot-path query with a few more questions in mind. What concrete source reaches it? Does a lambda capture the instance or local state? How many times is the input enumerated, and does the next operation really need a materialized copy?
A decompiler helps when I cannot answer those questions from the source. An allocation profiler tells me whether the generated delegates or iterators appear often enough to investigate. I then compare the alternatives on the target framework and inputs that matter to the application.
The smaller collection changes still need to earn their place
Not every investigation needs the larger rewrite above. Sometimes the collection already exposes enough information to avoid an allocation or a copy. These are the smaller changes I would consider once collection work appears in the profile.
- Represent empty arrays with
Array.Empty<T>()and empty enumerables withEnumerable.Empty<T>(). Modern C# collection expressions may provide an equally efficient and clearer form for the target type. - Give a collection its final capacity when the count is already known. Repeated growth allocates larger backing storage and copies existing elements.
- Keep concrete collection types available inside a measured hot path when their specialized enumeration matters. Do not narrow a public API merely to save an internal type check.
- Use pattern matching or
Enumerable.TryGetNonEnumeratedCountwhen code needs a count but should not walk a lazy sequence just to obtain one. - Delay creating a collection until the method knows it needs one. Validate boundary conditions and take early exits first.
Lower-level tools such as CollectionsMarshal, MemoryMarshal, and Unsafe can expose backing memory or remove checks. They also bypass abstractions and safety guarantees. I would reach for them only after ordinary collection code remains visible in the profile and tests protect the assumptions.
Keep the readable query until evidence objects
LINQ remains my default for code where it expresses the operation clearly. I replace it when a profile points at the query, inspection explains the cost, and a benchmark shows that the replacement earns its maintenance burden.
That threshold is higher for a twenty-line collection refactoring than for a four-line loop. It is also higher in application setup code than in an SDK path executed for every message.
The part of this contribution I keep coming back to is the difference between the two changes. Removing one delegate was easy to justify. Reshaping the collection handling needed knowledge of the SDK’s callers. Both started in the same few lines of LINQ, but they asked very different things of the people maintaining the code.
Further reading
The .NET 10 performance article is the best starting point for seeing how much LINQ behavior has changed since the original SDK contribution, and for the curious, you can already take a sneak peek at the .NET 11 performance improvements.
- Performance improvements in .NET 10: LINQ
- AMQP receiver benchmark source
- Enumerable.TryGetNonEnumeratedCount documentation
- Previous post: Why would you optimize this code at all?
- Next post: The lambda that allocates every time
Common questions
Should I stop using LINQ on hot paths?
No. Profile and benchmark the exact query first. Modern LINQ has many source-specific and operator-specific optimizations.
Do I need to read assembly?
Usually not. A decompiler and an allocation profiler often reveal enough. Assembly becomes useful when the remaining question concerns bounds checks, vectorization, inlining, or another just-in-time compiler decision.
What should I remember?
The generated work and production context decide whether the syntax needs replacing.


