End-to-end handling of a production complexity problem: spotting non-linear growth in metrics, profiling, identifying hidden O(n²) or N+1 patterns, fixing them, and preventing regressions.
List.Contains, FirstOrDefault, string.Concat, sorting, or many SQL calls per request (N+1).Dictionary/HashSet, batch queries, push filtering/sorting/paging to SQL, stream instead of buffering.Load problems correlate with request rate. Complexity problems correlate with data size per request. Break p95 latency down by customer and compare against that customer's row counts:
latency
^ x big customers
| x
| x
| x
| x
| x x x
+------------------------------------> orders per customer
linear would be a straight line; this curve bends up -> O(n^2)
If doubling the data roughly quadruples the time, suspect O(n²).
Copy (or synthesise) a dataset the size of the biggest customer into a test environment. Many teams never reproduce it because test data has 50 rows.
dotnet-trace collect -p <pid> --profile cpu-sampling, open in PerfView or Speedscope; wide frames under Enumerable.FirstOrDefault, List.Contains, List.Remove, String.Concat, OrderBy inside loops.Executed DbCommand.dotnet-counters showing allocation rate and Gen 2 GCs climbing; LOH growth from building huge strings or lists.| Culprit | Complexity | Fix |
|---|---|---|
list.FirstOrDefault/Contains inside a loop |
O(n · m) | build Dictionary/HashSet once |
| N+1 queries (lazy loading, query per row) | n round trips | Include, projection, or one batched WHERE Id IN (...) |
string += building exports |
O(R²) | StringBuilder or stream to the response |
RemoveAt(0) / Insert(0) on List |
O(n²) | Queue<T>, LinkedList, or iterate differently |
| Sorting whole sets per request | O(n log n) per call | sort in SQL with an index, or cache the sorted view |
| Loading all rows to page in memory | O(total rows) | Skip/Take or keyset paging in SQL |
| Recomputing aggregates each request | O(n) per call | precompute/materialise, or cache with invalidation |
Benchmark before and after at several sizes (BenchmarkDotNet [Params]), compare outputs for equality on real data, and release behind a feature flag if the change is risky.
ToList().A statement endpoint went from 300 ms to 25 s over a year for the largest tenant (180,000 transactions). Profiling showed two problems: a transactions.Where(t => t.AccountId == a.Id) inside a loop over 2,000 accounts (O(accounts × transactions)), and a lazy-loaded Category per transaction (180,000 SQL queries). We grouped transactions once with ToLookup, projected categories in the original query, and streamed the CSV. The endpoint dropped to 600 ms, and a CI performance test with 200,000 synthetic rows now guards it.
Assesses whether a senior candidate can take an algorithmic problem from production symptoms to a durable fix.
Green flags
Red flags
Run the operation at n, 2n, 4n and look at the ratio of times:
| Ratio when n doubles | Likely complexity |
|---|---|
| ~1× | O(1) or O(log n) |
| ~2× | O(n) |
| ~2.1–2.3× | O(n log n) |
| ~4× | O(n²) |
| ~8× | O(n³) |
The log-log slope of time versus n gives the exponent directly. This works on production metrics too: plot log(latency) against log(rows per tenant).
|------------------------------ GetStatement (100%) -------------------------------|
|---------- BuildLines (85%) ----------------------------|-- Serialise (10%) --|
|-- Enumerable.Where/FirstOrDefault (70%) --|-- Concat --|
Width is time. A library method like FirstOrDefault taking 70% of a request is the signature of a lookup inside a loop. Look at its caller, not at LINQ itself.
SELECT * FROM Accounts WHERE TenantId = @t -- 1 query
SELECT * FROM Transactions WHERE AccountId = @a1 -- n queries
SELECT * FROM Transactions WHERE AccountId = @a2
...
Each round trip costs about 0.5–2 ms even if the query is instant, so 2,000 accounts is several seconds of pure latency. Fixes in EF Core: Include (consider AsSplitQuery for multiple collections), projections with Select, or one query for all child rows then ToLookup in memory.
// O(a * t): scan transactions per account
foreach (var a in accounts)
a.Lines = transactions.Where(t => t.AccountId == a.Id).ToList();
// O(a + t): group once
var byAccount = transactions.ToLookup(t => t.AccountId);
foreach (var a in accounts)
a.Lines = byAccount[a.Id].ToList(); // ILookup returns empty for missing keys
This is exactly the hash join a database would choose.
[Fact]
public void Statement_scales_linearly()
{
var t1 = Time(() => Build(Generate(20_000)));
var t2 = Time(() => Build(Generate(80_000)));
// 4x data should be roughly 4x time; allow noise but catch quadratic (16x)
Assert.True(t2 < t1 * 8, $"Grew {t2 / t1:F1}x for 4x data");
}
Ratio tests are less flaky than absolute thresholds because they cancel out machine speed. Run them in a dedicated performance stage, not on every unit test run.
request -> [limits: page size, max items]
-> [algorithm: indexes, batching, streaming]
-> [database: indexes, query plans]
-> [observability: latency by tenant size, SQL count per request]
-> [CI: perf test at production n]
Also log n with each request (row counts, item counts). Without it you cannot correlate latency with data size.
Take any endpoint in your app that loops over a collection. Generate 1×, 4× and 16× the data, time it, and compute the ratios. Then run dotnet-trace collect --profile cpu-sampling during the 16× run and open the trace in Speedscope (dotnet-trace convert --format Speedscope). Find the widest frame you did not expect.
// BEFORE: O(accounts * transactions) CPU plus N+1 queries for categories
public async Task<Statement> GetStatementBefore(int tenantId)
{
var accounts = await db.Accounts.Where(a => a.TenantId == tenantId).ToListAsync();
var transactions = await db.Transactions.Where(t => t.TenantId == tenantId).ToListAsync();
var lines = new List<Line>();
foreach (var a in accounts)
foreach (var t in transactions.Where(t => t.AccountId == a.Id)) // scan per account
lines.Add(new Line(a.Name, t.Amount, t.Category.Name)); // lazy load per row
return new Statement(lines);
}
// AFTER: one query with projection, grouped once, O(a + t)
public async Task<Statement> GetStatementAfter(int tenantId, CancellationToken ct)
{
var accounts = await db.Accounts
.Where(a => a.TenantId == tenantId)
.Select(a => new { a.Id, a.Name })
.ToListAsync(ct);
var byAccount = (await db.Transactions
.Where(t => t.TenantId == tenantId)
.Select(t => new { t.AccountId, t.Amount, Category = t.Category.Name }) // join in SQL
.ToListAsync(ct))
.ToLookup(t => t.AccountId);
var lines = new List<Line>();
foreach (var a in accounts)
foreach (var t in byAccount[a.Id])
lines.Add(new Line(a.Name, t.Amount, t.Category));
return new Statement(lines);
}
// Log n so latency can be correlated with data size
logger.LogInformation("Statement built for {TenantId}: {Accounts} accounts, {Lines} lines in {ElapsedMs} ms",
tenantId, accounts.Count, lines.Count, sw.ElapsedMilliseconds);
Mistake: Scaling out (more pods, a bigger database) to fix a complexity problem. Why it is wrong: O(n²) work per request does not shrink with more instances; it only buys a few months while data keeps growing. Instead: Find and fix the algorithm first, then right-size the infrastructure.
Mistake: Guessing the cause instead of profiling. Why it is wrong: The slow part is often a library call or ORM behaviour that nobody suspects. Instead: Reproduce with realistic data and use dotnet-trace, PerfView, dotnet-monitor or the Application Insights profiler.
Mistake: Testing only with small datasets. Why it is wrong: Quadratic code is fast at n = 100 and catastrophic at n = 100,000. Instead: Keep a seeded, production-sized dataset and test at several sizes.
Mistake: Fixing CPU complexity and ignoring N+1 queries, or the other way around. Why it is wrong: Both often appear together, so fixing one leaves the endpoint slow. Instead: Count SQL calls per request as well as looking at CPU hotspots.
Mistake: Adding a cache to hide the problem. Why it is wrong: Cold starts, cache misses and invalidation still run the slow algorithm, and the cache adds staleness and memory pressure. Instead: Fix the algorithm first; add a cache only if it is still needed.
Mistake: Not logging data size. Why it is wrong: Without n in the logs you cannot tell growth from load. Instead: Log item counts with timings, and build dashboards of latency by tenant size.
Mistake: Asserting absolute timings in performance tests. Why it is wrong: Build agents vary in speed, so tests become flaky and get disabled. Instead: Assert the ratio of times between two sizes, in a dedicated performance stage.
The senior signal is diagnosing growth, not slowness. Seniors read dashboards for curves that bend with data size, separate that from traffic, and reproduce at realistic n before touching code. They know most production complexity bugs are not hand-written nested loops but LINQ lookups in loops, ORM lazy loading, string building and in-memory paging.
| Option | Fixes root cause? | When to use |
|---|---|---|
Index once (Dictionary, HashSet, ToLookup) |
Yes | Lookups or joins inside loops |
| Batch or project queries, disable lazy loading | Yes | N+1 query patterns |
| Push filter, sort and page to SQL with indexes | Yes | Large sets loaded into memory |
| Precompute or materialise aggregates | Yes, moves the cost | Read-heavy reports on slowly changing data |
| Cache the result | No | After the fix, for repeated identical reads |
| Scale out | No | Real traffic growth, not data growth |
They fix the root cause in a way the team can keep, and add guardrails (limits, ratio-based performance tests, logging n, alerts on the largest tenants' p95) so the next regression is caught in CI. They also lead the response: a clear incident update for stakeholders, a blameless review, and a short write-up of the pattern so other engineers recognise it in code review.
Count the work per line, including hidden loops in library calls. With O orders, L lines per order, P products:
products.FirstOrDefault per line: O(O · L · P).seen.Contains on a List: O(O²).report += copies the whole string each time: O(total chars²).Fix: build Dictionary<int, Product> once (O(P)), use HashSet<int> for seen, and use StringBuilder. Total becomes O(P + O · L + output size), linear in the input.
First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.
Big O describes how an algorithm's cost grows as input size n grows, ignoring constants and lower-order terms. It is an upper bound on growth, usually quoted for the worst case.
Dictionary lookup, array index.SortedDictionary lookup.List.Contains.Array.Sort, OrderBy.Amortised O(1) (like List.Add) means occasional expensive steps averaged over many cheap ones.
How to tell it (STAR)
At [company] our biggest customer started getting timeouts on statements. I noticed latency tracked their data size, not traffic, so I suspected the algorithm. I reproduced it with their data volume, profiled, and found a lookup inside a loop plus lazy loading doing [180,000] queries. I fixed both with a single projected query and a ToLookup, bringing it from [25 seconds] to [600 ms], and added a scaling test so it fails the build if it goes quadratic again.
Last updated 23 Oct 2026.