We’ve all seen it.
A single API endpoint makes three, four, sometimes ten separate queries to load one page. The developer is “being clever” by running them in parallel with Task.WhenAll or async calls.
From the client’s perspective, it feels faster.
From the database’s perspective, you just gave it four times the work for the same result.
Quick Note: What is Dapper?
Dapper is a micro-ORM (Object Relational Mapper) for .NET, created by the Stack Overflow team.
It’s designed to be:
- Fast — performance close to raw ADO.NET
- Lightweight — no complex change tracking, minimal overhead
- Simple — you write your own SQL and map directly to C# objects
Why use Dapper here?
- Full control over the SQL being sent
- Supports QueryMultiple, which lets you fetch multiple result sets in one roundtrip
- Perfect for performance-sensitive, read-heavy workloads
The Scenario
You want to display:
- Order header
- Order items
- Shipping details
- Payment transactions
You could do:
var order = await GetOrder(orderId);
var itemsTask = GetOrderItems(orderId);
var shippingTask = GetShippingDetails(orderId);
var paymentsTask = GetPayments(orderId);
await Task.WhenAll(itemsTask, shippingTask, paymentsTask);
That’s 4 separate trips to the database for one page.
Why Multiple Calls Hurt
From the database engine’s point of view:
-
More Network Trips
Each query means a separate TCP handshake and packet exchange. -
Connection Pool Pressure
Parallel calls take up more connections from the pool — bad news under load. -
Locking & Blocking
Overlapping queries on the same tables can block or deadlock each other. -
Plan Cache Pollution
Each query gets its own execution plan, adding churn to the cache. -
No Real Performance Gain
SQL Server can already parallelize work inside a single query. Splitting it into four doesn’t make the work more efficient.
The Developer’s Multitasking Trap
We developers love multitasking and multithreading. We see Task.WhenAll and think,
“If I run all my queries at once, it will be faster.”
Inside an app, that can be true. But when those calls all hit the same database, it’s not always faster — and can even be worse.
Why “more tasks” isn’t always “more speed” for databases
- More trips = more overhead — every query has network, parsing, and plan lookup costs. Four queries means paying that cost four times.
- Shared resources get busier — your queries compete for the same connections, CPU, memory, and locks inside the database.
- SQL Server can already parallelize — if one query is big enough, the optimizer will split it across cores. Breaking it into smaller queries removes that option.
- Locking and blocking increase — running multiple queries at once on the same data can cause them to wait on each other.
So how do we make calls faster?
- Reduce roundtrips — combine related queries into one using Dapper’s QueryMultiple or a single JOIN.
- Only select what you need avoid *SELECT ** to cut down data transfer.
- Use indexes — make sure your lookups and joins are supported by the right indexes.
- Let the DB do its job allow the optimizer to handle parallelism instead of forcing it from the client.
- Measure, don’t guess — sometimes the “fast” feeling of **Task.WhenAll **isn’t true under real load.
Bottom line:
Multithreading helps your app, but reducing roundtrips helps your database.
The Better Way: One Trip, Many Results
With Dapper’s QueryMultiple, you can send all four queries in one roundtrip:
const string sql = @"
SELECT OrderId, OrderDate, CustomerName
FROM Orders
WHERE OrderId = @orderId;
SELECT ProductName, Quantity, UnitPrice
FROM OrderItems
WHERE OrderId = @orderId;
SELECT Address, City, PostalCode, Country
FROM ShippingDetails
WHERE OrderId = @orderId;
SELECT PaymentDate, Amount, PaymentMethod
FROM Payments
WHERE OrderId = @orderId;";
using var grid = await conn.QueryMultipleAsync(sql, new { orderId });
var order = await grid.ReadSingleOrDefaultAsync<OrderDto>();
var items = (await grid.ReadAsync<OrderItemDto>()).ToList();
var shipping = (await grid.ReadAsync<ShippingDto>()).FirstOrDefault();
var payments = (await grid.ReadAsync<PaymentDto>()).ToList();
order.Items = items;
order.Shipping = shipping;
order.Payments = payments;
return order;
How SQL Server Parallelizes Queries
Many developers think:
“If I send multiple queries at the same time from C#, the database will work in parallel!”
Not exactly.
- SQL Server parallelism happens inside a single execution plan, not across unrelated queries.
- The optimizer decides to add Parallelism operators based on:
- Query cost (above cost threshold for parallelism)
- Available CPUs (max degree of parallelism)
If used:
- Operators like Parallel Scan and Parallel Hash Join split work into threads
- Gather Streams combines results before sending to the client
- With multiple separate queries:
- Each gets its own plan
- They fight for the same worker threads and connections
- Under load, this can actually reduce parallelism inside each query
Benchmark Results
We ran DbRoundtripBenchmark against SQL Server with ~10,000 seeded orders.
Four approaches were tested:
Analysis
- Sequential (4 trips): Slowest due to repeated network latency.
- Parallel (4 trips): Faster locally, but increases memory use and pool pressure.
- QueryMultiple (1 trip): Matches JOIN performance while being easier to shape and avoiding fan-out issues.
- JOIN (1 trip): Very fast for small related sets, but risks duplication with larger/more complex relationships.
Takeaway
If you’re doing:
await Task.WhenAll(QueryOrder(), QueryItems(), QueryShipping(), QueryPayments());
…against the same database, you’re not really parallelizing you’re just multiplying overhead.
Rule of Thumb:
- Let SQL Server parallelize inside a single plan, and keep your roundtrips to a minimum.
- Your database and your ops team will thank you.
Source Code
Full working implementation available here:
References
- Microsoft Docs – Parallel Query Processing
- Microsoft Docs – Execution Plan Caching and Reuse
- Microsoft Docs – Understanding and controlling parallelism in SQL Server
- Brent Ozar – Why parallelism is not the same as concurrency
- Erik Darling – Cost Threshold for Parallelism – What it really means
- Dapper Docs – QueryMultiple API