Fetching several product details one at a time makes a page wait for each network call in sequence. Starting every call together can reduce that wait, but applying the same approach to a large catalogue can overwhelm the service you depend on.
Task.WhenAll and Parallel.ForEachAsync both help coordinate asynchronous work. Choosing between them starts with the size of the workload, the capacity of shared resources and the result the caller expects.
Introduction
Imagine two features in a .NET application. A comparison page fetches details for up to four selected products. An overnight import processes tens of thousands of catalogue records.
Both features perform independent network calls. Both benefit from overlapping waits. Their control requirements are different.
The comparison page has a small, enforced input bound and needs an ordered result array. The import needs predictable concurrency, item-level outcomes and a recovery strategy when the downstream service slows or fails.
A concise LINQ expression can hide the difference. If it creates one task for every record, a large import can accumulate work far faster than any dependency can complete it.
This article explains what the two APIs actually do, how they handle results and failure, and how to choose limits that remain safe when many requests or workers run together. The examples assume modern .NET with asynchronous client methods that accept cancellation.
Separate Concurrency From Parallel Execution
Concurrency means several operations are in progress during the same period. Parallel execution means work runs simultaneously, usually on multiple processor cores.
Network operations often spend most of their time waiting. An asynchronous HTTP request can remain in progress while no worker thread is dedicated to waiting for its response. The continuation runs when the operation has more work to do.
That is why four asynchronous network calls can overlap without needing four threads blocked for their entire duration.
CPU work behaves differently. Compressing a large file or performing an expensive calculation consumes processor time while it runs. Starting more operations than the machine can execute does not create extra cores.
Neither API can change the nature of the underlying operation. A method that synchronously blocks before returning a task still blocks its caller during that interval. A method with Async in its name is not automatically implemented efficiently.
This distinction helps avoid a common repair attempt: wrapping an already asynchronous HTTP call in Task.Run. That usually adds scheduling without introducing the concurrency limit or capacity control the application actually needs.
What Task.WhenAll Does
Task.WhenAll returns a task that completes when all supplied tasks have completed. It joins existing operations; it does not impose a maximum number of operations in flight.
The methods that produce the tasks determine what starts and when. In typical asynchronous client code, calling the method begins execution immediately and returns when it reaches an incomplete await.
If you supply an enumerable created with Select, enumeration invokes those methods. Materialising the enumerable makes this start point explicit and stores the task collection once.
For generic tasks, the result array follows the input task order. A slow first product still occupies the first result slot even if the fourth product completes earlier. Microsoft's Task.WhenAll reference documents its completion, result ordering and fault behaviour.
An empty collection completes successfully, which is useful for optional work. That does not remove the need to validate whether an empty request is meaningful to the product.
Use It for a Small, Known Group
The comparison endpoint accepts at most four identifiers. After validation, it can start and join the calls:
var ids = request.ProductIds.Distinct().ToArray();
if (ids.Length is < 1 or > 4)
{
return Results.BadRequest("Choose between one and four products.");
}
Task<Product>[] tasks = ids
.Select(id => client.GetProductAsync(id, cancellationToken))
.ToArray();
Product[] products = await Task.WhenAll(tasks);
return Results.Ok(products);
The client method is assumed to return Task<Product>. The example deliberately validates the input before starting any downstream work.
Deduplication is a product choice. A comparison page usually does not need to fetch the same identifier twice. A batch operation might treat duplicate input positions as distinct work and therefore need to preserve them.
The important property is the enforced bound. A comment claiming that callers “normally send a few products” is weaker than validation that prevents an arbitrary workload.
For a few independent operations with different result types, start them into separate variables, await one combined task, then use the completed results through ordinary awaits or typed variables. Clear names can communicate the business purpose better than forcing everything into a uniform collection.
What Parallel.ForEachAsync Does
Parallel.ForEachAsync enumerates an input and processes it through concurrent asynchronous loop bodies. It limits the number of active bodies, which makes it useful for larger sequences.
The default maximum follows Environment.ProcessorCount. You can set an explicit maximum through ParallelOptions. That default is an API behaviour, not a recommendation that processor count is the ideal concurrency for every network workload.
Microsoft's Parallel.ForEachAsync reference includes overloads for synchronous and asynchronous input sequences.
The loop returns one task representing the whole operation. It does not return an array of body results. Result storage and ordering are the application's responsibility.
Apply an Explicit Limit to an Import
An import can process records with a bounded number of active operations:
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
recordIds,
options,
async (id, token) =>
{
await processor.ProcessAsync(id, token);
});
Eight is an illustrative starting point. It should be tested against the downstream service, database connections, memory use and concurrent jobs.
Pass the body-supplied token to cancellable operations. It represents the loop's cancellation behaviour, including cancellation requested as part of stopping work after failures.
A loop body that starts extra unawaited operations can defeat the intended limit. If each of eight bodies launches twenty requests and returns immediately, the application is no longer limiting downstream work to eight.
The body should await the complete unit of work whose concurrency you intend to control. If that unit contains nested fan-out, account for the multiplication explicitly.
Choose a Concurrency Limit From Capacity
A useful starting estimate comes from throughput and service time. If one operation spends about 200 milliseconds in a dependency, five continuously active operations could theoretically sustain about 25 operations per second.
That arithmetic assumes stable latency, independent work and no other bottleneck. It is a planning estimate, not a throughput promise.
Real systems have variable response times, connection limits, throttling, retries and competing callers. Increasing concurrency can raise throughput at first, then increase queueing without producing more completed work.
Measure a series of modest limits under a representative workload. Look for the point where throughput gains flatten while tail latency, errors or memory continue rising.
Keep some capacity for interactive traffic if the import shares a dependency with web requests. A background job that maximises its own throughput can make the overall product worse.
Document the chosen value and the evidence behind it. A named configuration setting with a sensible upper bound is easier to operate than a magic number copied across many call sites.
Preserve Results and Ordering Deliberately
For Task.WhenAll<T>, the returned array already preserves input task order. An additional concurrent bag would discard that useful relationship and add unnecessary complexity.
For Parallel.ForEachAsync, distinct preallocated slots can preserve order when the input is already materialised:
var ids = productIds.ToArray();
var results = new Product?[ids.Length];
await Parallel.ForEachAsync(
Enumerable.Range(0, ids.Length),
new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
},
async (index, token) =>
{
results[index] = await client.GetProductAsync(
ids[index], token);
});
Each iteration writes to its own array index, and the caller reads the array only after successful completion. The example assumes each index is visited once.
If the loop fails, some slots may remain empty. Do not quietly treat a partially populated array as a complete successful response. Decide whether partial outcomes belong in the API contract.
An ordinary List<T> is unsafe for concurrent writes. A concurrent collection can collect unordered outcomes, while a dictionary keyed by an identifier can support lookup. Duplicate identifiers require a deliberate policy because a dictionary may replace or reject repeated keys.
Ordering also affects streaming. If results must be emitted in input order, a slow early operation can hold back later completed results. Supporting completion-order output can reduce that waiting, but changes what the consumer sees.
Understand Task.WhenAll Failure Semantics
Task.WhenAll waits for all supplied tasks. One fault does not automatically cancel its siblings.
If any supplied task faults, the combined task faults. If none faults but at least one is cancelled, the combined task is cancelled. Otherwise it completes successfully.
Awaiting the combined task propagates an exception. That single observed exception should not be treated as a complete list of all item failures.
Retain the combined task when the diagnostic requirement includes every failure:
Task<Product[]> combined = Task.WhenAll(tasks);
try
{
return await combined;
}
catch
{
if (combined.Exception is { } aggregate)
{
foreach (var error in aggregate.Flatten().InnerExceptions)
{
logger.LogError(error, "A product lookup failed");
}
}
throw;
}
Cancellation alone does not populate the task's Exception property with fault exceptions. The original exception is rethrown so the caller's normal error or cancellation handling remains in control.
For useful item-level diagnostics, also retain the relationship between each task and its identifier. A list of exception messages without product identifiers may be insufficient to retry the failed inputs.
Account for Synchronous Task Creation Failures
The call that creates a task can sometimes throw before returning one, particularly when an API performs synchronous argument validation in a non-async wrapper.
If task creation fails halfway through enumerating inputs, earlier operations may already be running. There may never be a complete task array to pass to WhenAll.
Validate inputs before starting work where possible. For more complicated orchestration, deliberately capture each started task and ensure it is observed and awaited even if later creation fails.
Do not assume every exception related to asynchronous code arrives only at the final await. The start phase, the operation phase and the result-processing phase can each fail for different reasons.
Understand Parallel.ForEachAsync Failure Behaviour
When a loop body throws, the implementation requests internal cancellation so other workers can stop. Already running iterations can still complete, and operations must cooperate with cancellation.
Do not rely on every input being visited after the first unexpected failure. A loop intended to stop on infrastructure failure and a batch intended to record every invalid record are different workflows.
If individual validation failures are expected, catch those specific exceptions within the body and record an outcome. Let unexpected failures propagate when continuing would be misleading or harmful.
For example, a malformed catalogue record might produce a failed-item result while other records continue. A complete authentication failure to the destination service may justify stopping the whole import.
Avoid a broad catch that turns every exception into “record skipped”. That can make a broken deployment appear to complete successfully with thousands of missing updates.
If several bodies fail, the returned task can contain multiple fault exceptions. As with other task-based APIs, keep structured per-item diagnostics rather than relying solely on whichever exception is observed by one await.
Make Partial Success a Product Decision
The comparison page might require all selected products. If one lookup fails, returning an incomplete array could mislead the user.
An import has different needs. It may be useful to process valid records, record individual failures and allow a later retry of those failures.
Model that outcome explicitly:
public sealed record ImportOutcome(
Guid RecordId,
bool Succeeded,
string? ErrorCode);
Store a stable error code suitable for the caller and log technical details separately. Do not expose raw dependency exception messages that may contain private URLs, credentials or internal information.
A successful item outcome should mean the intended durable work completed. If the body merely queued another untracked task, the import cannot honestly claim the record was processed.
For long-running imports, persist progress as work completes. Holding every outcome in memory until the final item finishes creates a large loss window if the process stops.
Define job-level status separately from item-level status. “Completed with 12 rejected records” can be meaningful; “Succeeded” after silently swallowing all errors is not.
Propagate Cancellation and Deadlines
Cancellation is cooperative. Passing a token does not forcibly terminate arbitrary code, undo a database write or retract a remote request that already committed.
Use the request or job token to stop work that is no longer needed. Pass it through HTTP calls, database operations and waits that support cancellation.
A timeout can be represented with a linked token source:
using var deadline = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(10));
var tasks = ids
.Select(id => client.GetProductAsync(id, deadline.Token))
.ToArray();
var products = await Task.WhenAll(tasks);
The timeout applies to this group of operations. Each operation may also have its own dependency timeout, and the interaction should be understandable.
Do not confuse cancelling a wait with cancelling the underlying work. For example, limiting how long the caller waits for a task does not necessarily stop the operation represented by that task.
If a remote write times out, its outcome may be uncertain. Use operation identifiers, idempotent commands or status queries where the business operation requires safe retry. Neither orchestration API provides that guarantee automatically.
Keep Dependencies Independent
Independent record identifiers do not guarantee independent service instances. Several iterations can call the same processor object, which may retain mutable fields or a shared database context.
EF Core does not support concurrent operations on one DbContext. Microsoft's DbContext threading guidance requires separate contexts for truly parallel operations or sequential awaiting on a shared context.
Combining suitable database work into one query can be better than creating many contexts. Parallelism is not a substitute for a good query shape.
If each background record is an independent unit of work, a scope or context per iteration can make ownership explicit. Dispose it after that iteration's awaited work completes.
The input source also matters. Streaming rows from a context while loop bodies issue queries through that same context can overlap database operations unexpectedly.
Materialise a bounded page of identifiers before processing, or use a separate read context and independent processing contexts with a carefully designed streaming boundary. Include connection and memory capacity in the decision.
Shared counters, builders and collections also need review. A processor that reuses one mutable request object for all iterations can send mixed data even when the HTTP client itself supports concurrent calls.
Distinguish Concurrency Limits From Rate Limits
A concurrency limit controls how many operations are active at once. A rate limit controls how many operations are admitted during a period.
With eight active calls and very fast responses, the application can still send hundreds of requests per second. Therefore, eight concurrent operations does not automatically respect a dependency's requests-per-minute quota.
Conversely, a low request rate can still create too many in-flight operations when responses become extremely slow. Some systems need both controls.
Microsoft's rate limiting API guidance describes using limiters with outgoing HTTP work. Choose a policy that matches the downstream contract rather than assuming a loop limit covers every form of capacity.
Queued work also needs a bound. If incoming work arrives faster than it completes for a sustained period, an unlimited queue moves the overload into memory and waiting time.
Define admission behaviour: wait briefly, reject with an appropriate response, defer to durable storage or shed optional work. The caller should understand whether the operation was accepted.
Account for Total Application Demand
A limit of eight inside one HTTP request is local to that request. If 100 requests each run that loop, the application can attempt hundreds of downstream operations.
The same multiplication occurs across background jobs and application instances. A process-wide limiter coordinates only that process unless additional infrastructure provides a wider boundary.
Identify the resource the limit protects. A database connection pool is usually associated with a process and connection configuration. A vendor quota might be shared across the entire organisation.
Place admission control at the boundary that can actually protect that resource. A shared dependency client may own a process-level limiter; a durable work queue may control fleet-level execution.
Partition limits carefully when tenants share capacity. One large import should not indefinitely delay every small interactive request. Fairness and priority are part of the design, not properties supplied by WhenAll.
Observe active work and queued work separately. A healthy-looking concurrency count can conceal a growing backlog and increasingly stale results.
Avoid Replacing One Unbounded Pattern With Another
A semaphore can impose a concurrency bound around Task.WhenAll, but creating one waiting task for every item still has allocation and retention costs.
For a modest list, that can be entirely acceptable. For millions of inputs, a worker loop or bounded channel is often easier to keep within predictable memory limits.
Parallel.ForEachAsync can consume an asynchronous sequence, but the producer and body still need compatible ownership and failure behaviour. A slow or faulting producer can limit or stop the whole operation.
A channel-based pipeline becomes useful when stages need different limits: reading files, parsing records and writing results may each have different capacities. Microsoft's channels documentation explains bounded channels and producer-consumer coordination.
Do not introduce a pipeline solely because it looks more advanced. A small, bounded task group is simpler to understand and often entirely sufficient.
The choice should follow the workload: a few known tasks, a larger independent sequence, or a multi-stage stream with explicit buffering.
Treat CPU Work Separately
For genuinely CPU-heavy work, choose parallelism based on available processor capacity and the other work sharing the process.
A web application may already be executing many requests concurrently. Running a large parallel calculation inside each request can increase contention and reduce overall throughput.
Task.WhenAll does not make a synchronous loop parallel merely because its results are wrapped in tasks. The work must actually be scheduled or implemented asynchronously in a meaningful way.
Parallel.ForEachAsync can coordinate asynchronous bodies, but a predominantly synchronous CPU loop may be clearer with other parallel APIs or a dedicated worker, depending on cancellation and result requirements.
For substantial batches, move execution to a background service with its own resource budget. The user-facing request can accept and track a job instead of holding a connection open throughout the computation.
Measure CPU, allocations and garbage collection alongside latency. A change that reduces one operation's elapsed time while doubling resource consumption can be a poor choice under production concurrency.
Work Through a Troubleshooting Example
Suppose a catalogue import uses Task.WhenAll across 20,000 records. It is fast against a local stub, but production shows throttling, timeouts and growing memory.
The first investigation should measure how many calls are active, how many tasks are waiting, and how the dependency responds as concurrency increases. The local stub did not represent production capacity or latency.
Replace the unbounded start with a bounded loop. Begin with a conservative limit, record per-item outcomes and propagate cancellation. Keep the workload and input data consistent while comparing runs.
If throttling remains high, inspect the vendor's rate policy. A concurrency limit may still exceed its request budget. Add appropriate rate control and ensure retries respect backoff and the operation's overall deadline.
If throughput remains poor with low dependency utilisation, inspect another stage. Serial result writes or expensive parsing may now dominate. Increasing network concurrency would not fix that bottleneck.
The verified result should include stable memory, acceptable downstream errors and predictable completion time. “The code now uses Parallel.ForEachAsync” is an implementation fact, not evidence that the problem is solved.
Verify Correctness Before Optimising Throughput
Use a small controlled input whose results can be checked independently. Include duplicate identifiers, missing records and out-of-order completion.
Then exercise one expected item failure, one infrastructure failure and cancellation while several operations are in progress. Confirm which items complete, which remain pending and what the caller is told.
Measure maximum active operations with a test double or diagnostic counter. This verifies that nested work or unawaited tasks have not defeated the intended bound.
Test representative large inputs for memory behaviour. An implementation can respect the downstream concurrency limit while retaining every input, result and exception unnecessarily.
Avoid tests tied to exact timing. The useful properties are ordering, complete observation of tasks, correct outcome reporting and a concurrency ceiling. Those remain meaningful across faster machines and different scheduling decisions.
Design Retries Around the Unit of Work
Retries interact with concurrency limits. If a loop body holds its place while waiting for a retry delay, that place remains part of the active-body limit even though no network call is currently running.
That can be a useful conservative policy during an outage, because failed records do not immediately admit unlimited replacements. It can also reduce throughput when a few records experience long backoff periods. Choose deliberately according to the job's fairness and completion requirements.
An alternative is to persist failed work with a future eligibility time and let another scheduling pass retry it. That needs durable state and a clear attempt policy, but it prevents one long delay from occupying an execution slot indefinitely.
Whichever approach you choose, retry only failures that the dependency contract treats as transient. A validation rejection or unauthorised request is unlikely to improve after the same payload is sent again.
Track attempts under the same operation identity so a timeout after a successful remote write cannot create duplicates. Record the final item outcome separately from intermediate attempt failures.
Avoid placing a retry around an entire Task.WhenAll group when only one item failed, unless repeating the successful operations is explicitly safe. Otherwise a single unavailable product can cause the application to fetch or update every successful item repeatedly.
For imports, the natural retry boundary is often the individual idempotent record operation. For an all-or-nothing business workflow, the correct boundary may require a different transaction or compensation design entirely.
Document the Choice Where the Work Starts
Leave a brief explanation near the orchestration boundary stating the input limit, protected dependency and partial-success policy. These are the assumptions a future change is most likely to violate.
For the comparison page, the essential fact is that validation permits at most four distinct products. For the import, it is that each body owns one complete record operation and the configured concurrency reflects shared destination capacity.
If a later developer adds a second downstream call inside each iteration, that explanation prompts a review of total demand. If the product removes the comparison limit, it prompts a review of the unbounded task creation.
This documentation should describe the reason for the control, rather than restating what the API name already says. The limit is part of the feature's operating assumptions and deserves the same attention as its response contract.
Summary
Use Task.WhenAll to join a small, controlled group of tasks when result collection is straightforward. Use Parallel.ForEachAsync when a larger independent sequence needs an explicit limit on active work.
In both cases, define ordering, failure handling, partial success, cancellation and dependency ownership before tuning throughput. A local loop limit must also be considered alongside application-wide demand and downstream rate policies.
The right approach completes useful work predictably while keeping shared resources within their capacity. That is a stronger goal than starting the largest possible number of tasks.
