📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

A .NET API endpoint was fast in testing but gets slower every month as production data grows, and now times out for your biggest customers. How would you find and fix an algorithmic complexity problem, and stop it happening again?

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.

Senior RealWorldScenario Advanced algorithms big-o performance production profiling time-complexity

🎯 Key points

  • Correlate latency with data size per request (rows per tenant), not just traffic; a curve that bends upward with n points to O(n²) or worse
  • Measure growth empirically: when n doubles, about 2× time suggests O(n), about 4× suggests O(n²); the log-log slope gives the exponent
  • Reproduce with production-sized data, then profile: dotnet-trace or PerfView for CPU, dotnet-counters for GC and allocations, dotnet-monitor or Application Insights for production, OpenTelemetry traces for SQL calls per request
  • Common culprits: `FirstOrDefault`/`Contains` inside loops, N+1 queries from lazy loading, `string +=` in loops, `List<T>.RemoveAt(0)`, and in-memory sorting or paging
  • Fix the algorithm: index once with `Dictionary<TKey,TValue>`, `HashSet<T>` or `ToLookup`, batch or project queries, push filtering, sorting and paging to SQL, and stream large responses
  • Scaling out or adding a cache hides O(n²) for a while but does not fix it
  • Prevent regressions with ratio-based performance tests at several sizes in CI, request limits, and logging n with every timing
  • Communicate the risk in business terms ("the largest tenant doubles next year, so this takes 100 s") and lead a blameless review so the team learns the pattern

⏱ 30-second answer

  1. Confirm it is complexity, not load: plot latency against the customer's data size. A curve that bends upward with n (not with traffic) points to O(n²) or worse.
  2. Profile with real-sized data: dotnet-trace/dotnet-counters or Application Insights profiler; look for wide frames in List.Contains, FirstOrDefault, string.Concat, sorting, or many SQL calls per request (N+1).
  3. Fix the algorithm: index with Dictionary/HashSet, batch queries, push filtering/sorting/paging to SQL, stream instead of buffering.
  4. Prevent it: performance tests with production-sized n, latency-by-size dashboards, and limits on unbounded inputs.

🗣 Full answer (senior)

1. Recognise the shape

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²).

2. Reproduce with realistic 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.

3. Profile, do not guess

  • CPU-bound: 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.
  • I/O-bound: traces in Application Insights/OpenTelemetry showing hundreds of SQL spans per request (N+1); EF Core logging of Executed DbCommand.
  • Memory: dotnet-counters showing allocation rate and Gen 2 GCs climbing; LOH growth from building huge strings or lists.

4. Typical culprits and fixes

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

5. Verify and release safely

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.

6. Prevent recurrence

  • Performance tests in CI at production-like n, failing if time grows faster than expected.
  • Dashboards of latency by tenant size, and alerts on the largest tenants' p95.
  • Limits: maximum page size, maximum items per request, pagination everywhere.
  • Review checklist: lookups in loops, query-per-item, unbounded ToList().

Production example

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.

🔍 What the interviewer is checking

Assesses whether a senior candidate can take an algorithmic problem from production symptoms to a durable fix.

Green flags

  • Distinguishes data-size growth from traffic load using metrics.
  • Reproduces with realistic data and profiles with real tools.
  • Recognises hidden O(n²) (lookups in loops, string concat) and N+1 queries.
  • Fixes with indexing, batching, SQL-side work and streaming.
  • Adds prevention: performance tests, logging n, limits, dashboards.

Red flags

  • Jumps to "add more servers" or "add Redis".
  • No profiling, only guessing.
  • Treats it as a one-off fix with no guardrails.
  • Cannot explain the complexity of the original code.

📚 Learn in depth

1. Measuring growth empirically

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).

2. Reading a flame graph

|------------------------------ 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.

3. The I/O version of O(n²): N+1

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.

4. In-memory join patterns

// 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.

5. Guarding with tests

[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.

6. Defence in depth

 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.

7. Try it yourself

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.

💻 Code

// 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);

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate names lookups in loops as a culprit Why: Checks they can analyse and rewrite the code line by line
    How to answer

    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.

  2. When: Candidate says they would profile Why: Checks hands-on profiler skills
    How to answer

    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.

  3. When: Candidate says the curve looks quadratic Why: Checks the Big O fundamentals behind the diagnosis
    How to answer

    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.

    • O(1): Dictionary lookup, array index.
    • O(log n): binary search, SortedDictionary lookup.
    • O(n): a single loop, List.Contains.
    • O(n log n): Array.Sort, OrderBy.
    • O(n²): nested loops over the same data.
    • O(2ⁿ): naive recursive Fibonacci, trying every subset.

    Amortised O(1) (like List.Add) means occasional expensive steps averaged over many cheap ones.

🛠 Real-world problems: how you handle them

Statement endpoint slowed from 300 ms to 25 s as tenants grew
Situation
A B2B finance API generated account statements. The largest tenant grew to [180,000] transactions across [2,000] accounts over a year.
Signals
p95 latency for that tenant rose steadily from [300 ms] to [25 s]; gateway timeouts at [30 s]; other tenants unaffected; database CPU elevated with thousands of small queries per request.
Investigation
Plotted latency against transactions per tenant and saw a quadratic curve. Reproduced with a copy of the tenant's data. dotnet-trace showed most CPU in Enumerable.Where inside a loop over accounts; EF Core logs showed one Category query per transaction (lazy loading).
Resolution
Projected the category name in the main query, grouped transactions once with ToLookup, and streamed the CSV response. Latency dropped to [600 ms].
Prevention
CI performance test with [200,000] synthetic rows asserting near-linear growth; lazy loading disabled; dashboard of p95 by tenant size; SQL-calls-per-request metric with an alert.

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.

↩ Usually asked after

More in Algorithms

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me