- #dotnet
- #csharp
- #performance
- #entity-framework-core
- #software-architecture
- #backend
From 50 Minutes to 90 Seconds: A Postmortem on Scaling a Bulk Data Export Feature
How a routine 'Export to Excel' button turned into a deep-dive on ORM internals, connection-pool math, streaming OpenXML ZIP generation, and bounded concurrency — taking export runtime from ~50 minutes to ~90 seconds.
On this page
Every internal tool eventually grows an "Export to Excel" button. It starts innocently: an administrator or educator wants a spreadsheet of records instead of clicking through a paginated UI. But when executed against an enterprise-scale dataset, that innocent button can become a 50-minute bottleneck or crash with an unexpected ORM exception.
An "export roster to Excel" endpoint in a web application worked reliably for small classrooms (40–100 records). However, when pointed at an enterprise-scale dataset with ~8,600 rows and ~6,700 embedded student ID photos in a 152 MB file, two cascading failures occurred:
- An immediate crash with
System.InvalidCastExceptionoriginating from insideMicrosoft.EntityFrameworkCore. - Once that was resolved, a 40–50 minute execution duration that tripped reverse-proxy HTTP timeouts and froze browser tabs.
Four independent bottlenecks were stacked on top of each other: sequential
paginated API fetches, sequential downstream database lookups, an
O(n²) in-memory join, and a DOM-based spreadsheet library that
chokes when embedding thousands of images. Addressing all four—alongside an ORM
null-handling regression—slashed end-to-end processing time from ~50 minutes
to ~90 seconds (a ~33x overall improvement) with zero data loss.
Fixing 4 independent bottlenecks: sequential API pagination (2m 43s → 31s), sequential DB queries (3m → 49s), O(n²) in-memory joins (7.7s → 5ms), and DOM-based spreadsheet serialization (45m → 429ms) via direct streaming OpenXML ZipArchive.
System Overview
Stripped of proprietary business logic, the bulk export endpoint coordinates four distinct layers across external APIs, relational stores, in-memory processing, and document serialization:
Fetches upstream roster records across dozens of paginated REST endpoints.
Enriches rows with secondary SIS metadata and binary photo BLOBs.
Correlates student records, custom attributes, and photo bytes via fast indexing.
Emits ZIP parts and DrawingML anchors directly to an output stream without DOMs.
The complete runtime sequence across distributed boundaries is illustrated below:
Incident #1: The Crash (ORM Internals & Boundary Hygiene)
The Symptom
Exporting a large dataset containing several thousand student records immediately aborted with an unhandled exception:
System.InvalidCastException: Object must implement IConvertible.
at System.Convert.ChangeType(Object value, Type conversionType, IFormatProvider provider)
at Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter.Sanitize[T](Object value)
at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.Execute[TState,TResult](...)The exception was traced to an innocuous-looking query expression:
var matches = queryable.Where(record => idsToMatch.Contains(record.ExternalId));Where idsToMatch was an in-memory List<string>. While developers often assume .Contains() against a list is evaluated in-memory as a simple hash/equality check, against an IQueryable it is captured as an Expression Tree and translated into SQL dialect by the ORM provider.
Following the Trail
A careful investigation unraveled multiple interacting layers:
- Unclean Upstream Data: The external REST API returned thousands of records where the majority had
nullexternal identifiers. The unvalidated list was passed directly into.Contains(record.ExternalId). - The Parameter Translation Mode Myth: A common hypothesis is that large collections exceed SQL Server's 2,100 parameter ceiling and force a fallback. However, in
Entity Framework Core 10, expanding collections into multiple scalar parameters (WHERE Id IN (@p1, @p2, ...)) is the default. The alternative single-parameter mode (ParameterTranslationMode.Parameter, which serializes the collection as a JSON array unpacked server-side viaOPENJSON) was the default in EF Core 8/9 and remains an opt-in mode in EF Core 10. - The Upstream Regression: The project had retained
ParameterTranslationMode.Parameter. In EF Core 10.0.2, passing anullelement inside a nullable collection to.Contains()under this mode causedValueConverter.Sanitize<T>()to invokeConvert.ChangeTypeon anullvalue, triggering the exactInvalidCastException. This issue is tracked publicly as dotnet/efcore#37537 and resolved in PR #38066. - Mismatched Transitive Packages: A dependency audit revealed mixed package versions across projects (
Microsoft.EntityFrameworkCorewas patched, but a provider package was pinned to an older build).
- ✓Defensively filter nulls, whitespace, and duplicates before querying
- ✓Chunk collections into bounded batches (e.g. 1,000 items) to protect parameter caps
- ✓Align all EF Core package versions across transitive project references
- ✓Use EF Core 10 default MultipleParameters mode for collection queries
- ✗Passing raw, unfiltered collections directly into ORM expression trees
- ✗Assuming .Contains() against IQueryable behaves identically to in-memory LINQ
- ✗Unbounded IN clauses that breach database parameter and query limits
- ✗Relying on legacy JSON-parameter translation modes without validating patch levels
The Fix: Defend the Boundary, Then Align Dependencies
public async Task<List<StudentRecord>> GetEnrichmentRecordsAsync(List<string> rawIds)
{
if (rawIds == null || rawIds.Count == 0)
return new List<StudentRecord>();
// 1. Defend the boundary: filter nulls, empty strings, and duplicates
var validIds = rawIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList();
if (validIds.Count == 0)
return new List<StudentRecord>();
var queryable = (await _repository.GetQueryableAsync()).AsNoTracking();
// 2. Chunk defensively to respect database engine parameter limits
var result = new List<StudentRecord>();
foreach (var batch in validIds.Chunk(1000))
{
var batchIds = batch.ToList();
var batchResult = await queryable
.Where(record => batchIds.Contains(record.ExternalId))
.ToListAsync();
result.AddRange(batchResult);
}
return result;
}# Verify that all EntityFrameworkCore packages share identical version numbers
dotnet list <project>.csproj package --include-transitive | Select-String 'EntityFrameworkCore'Diagnostic Telemetry
Adding privacy-safe structural logging before the database boundary provided immediate clarity:
var validIds = rawIds.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList();
logger.LogInformation(
"Export ID diagnostics: Total={TotalCount}, Null={NullCount}, DistinctValid={ValidCount}",
rawIds.Count,
rawIds.Count(id => id is null),
validIds.Count);Output: Total=3180, Null=3131, DistinctValid=49. This revealed that 98.4% of rows lacked external keys upstream, instantly clarifying why the crash was triggered only on specific datasets.
Incident #2: The Slowness (4 Compounded Bottlenecks)
With the crash resolved, exporting a complete dataset of ~8,600 rows still took 40–50 minutes. Profiling each stage with structured Stopwatch timers uncovered an evidence-backed distribution:
Scroll horizontally to see all columns.
| Pipeline Stage | Baseline Runtime | % of Total Time | Primary Bottleneck |
|---|---|---|---|
| 1. Paginated Ingestion (External API) | ~2 min 43 s | ~6% | Sequential HTTP roundtrips across 87 pages |
| 2. Enrichment DB Lookup | ~2.1 s | <1% | Fast indexed single query |
| 3. Photo DB Lookups | ~3 min 00 s | ~6% | Sequential chunked queries over a single connection |
| 4. In-Memory Merging / Join | ~7.7 s | <1% | O(n²) linear search inside row loops |
| 5. Spreadsheet Generation (152 MB, ~6,700 images) | ~40–50 min | ~85% | DOM object graph allocation and image re-compression |
Bottleneck 1: Sequential Pagination Against External API
The upstream REST API paginated records at 100 items per page (87 total pages). The original implementation fetched pages serially:
// ❌ ANTI-PATTERN: Serial await per page over HTTP
string url = BuildPageUrl(page: 1);
while (url != null)
{
var response = await httpClient.GetAsync(url);
var page = await ParseAsync(response);
allRecords.AddRange(page.Items);
url = GetNextPageUrl(response);
}At ~1.9 seconds per roundtrip across 87 pages, serial execution guaranteed a ~2m 43s floor regardless of server CPU or memory.
The Fix: Bounded Fan-Out via SemaphoreSlim
The REST API exposes the total page count via a Link response header (rel="last"). We parse this on the first request and fan out remaining requests with SemaphoreSlim to bound concurrency:
var firstPageResponse = await httpClient.GetAsync(BuildPageUrl(page: 1));
var firstPage = await ParseAsync(firstPageResponse);
allRecords.AddRange(firstPage.Items);
int lastPage = TryGetLastPageFromLinkHeader(firstPageResponse.Headers);
if (lastPage > 1)
{
using var throttle = new SemaphoreSlim(8); // Cap concurrent upstream HTTP requests
var pageTasks = Enumerable.Range(2, lastPage - 1).Select(async pageNumber =>
{
await throttle.WaitAsync();
try
{
var response = await httpClient.GetAsync(BuildPageUrl(pageNumber));
return response.IsSuccessStatusCode
? await ParseAsync(response)
: new PageResult(Array.Empty<Record>());
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to fetch page {PageNumber}", pageNumber);
return new PageResult(Array.Empty<Record>());
}
finally
{
throttle.Release();
}
});
var pages = await Task.WhenAll(pageTasks);
foreach (var page in pages)
allRecords.AddRange(page.Items);
}If an API does not provide total page counts via headers, use a sliding window: fetch pages N..N+7 concurrently, verify if any indicate the end of the collection, and terminate early without guessing.
Benchmark Result: Runtime dropped from 2 min 43 s → 31.2 s (~5.2x faster).
Bottleneck 2: Sequential Batched Database Lookups
Photo lookups for thousands of records required batching to respect database IN parameter limits (1,000 items). While chunking was present, batches executed serially over a single database connection:
// ❌ ANTI-PATTERN: Correct chunking, but executed serially
foreach (var batch in ids.Chunk(1000))
{
var batchIds = batch.ToList();
var photos = await dbContext.Photos
.Where(p => batchIds.Contains(p.OwnerId))
.ToListAsync();
result.AddRange(photos);
}The Fix: Parallel Bounded DbContext Execution
By creating short-lived, dedicated DbContext instances per task and throttling via SemaphoreSlim(4), queries execute concurrently without saturating connection pools:
var batches = ids.Chunk(1000).ToList();
using var throttle = new SemaphoreSlim(4); // Sized to match database connection pool headroom
var batchTasks = batches.Select(async batch =>
{
await throttle.WaitAsync();
try
{
var batchIds = batch.ToList();
// DbContext is NOT thread-safe: create an isolated instance per task
using var dbContext = CreateDedicatedDbContext();
return await dbContext.Photos
.AsNoTracking()
.Where(p => batchIds.Contains(p.OwnerId))
.ToListAsync();
}
finally
{
throttle.Release();
}
});
var results = await Task.WhenAll(batchTasks);
foreach (var batch in results)
photos.AddRange(batch);DbContext instances are not thread-safe and must never be shared across concurrent asynchronous operations. Sharing an instance will throw InvalidOperationException or cause silent query corruption. Always instantiate a dedicated context per concurrent worker.
Benchmark Result: Photo querying dropped from 3 min 00 s → 49.2 s (~3.7x faster).
Bottleneck 3: O(n²) Join Hiding in Plain Sight
Once records and photo records were in memory, matching each row to its corresponding photo was performed via a linear scan:
// ❌ ANTI-PATTERN: O(n × m) linear scan for every row
var photo = photos.FirstOrDefault(p => p.OwnerId == record.ExternalId);While harmless for 40 rows, with ~8,600 records against ~6,700 photos, this required up to 57 million equality evaluations—and was duplicated across multiple transformation passes.
The Fix: O(1) Pre-Indexed Hash Lookup
Building a dedicated dictionary before the loop transforms linear scans into instantaneous hash lookups:
public static Dictionary<string, byte[]> BuildPhotoLookup(IEnumerable<PhotoRecord> photos)
{
var lookup = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
foreach (var photo in photos)
{
if (string.IsNullOrEmpty(photo.OwnerId) || photo.Bytes is not { Length: > 0 })
continue;
lookup[photo.OwnerId] = photo.Bytes; // First match wins
}
return lookup;
}
// Inside the row iteration loop — O(1) instantaneous lookup:
photoLookup.TryGetValue(record.ExternalId, out var photoBytes);Benchmark Result: In-memory merging dropped from 7.7 s → 5 ms (~1,500x faster).
Bottleneck 4: DOM-Based Spreadsheet Serialization
The single largest bottleneck consumed 85% of total request time (40 to 50 minutes). The spreadsheet library constructed the entire workbook as a deep in-memory Document Object Model (DOM)—allocating nodes for every cell, formula, style, and embedded image—before serializing and deflating the entire package at the end.
When embedding ~6,700 images into a 152 MB file, the DOM library caused severe GC pressure, repetitive tree traversals, and wasted CPU cycles attempting to compress already-compressed JPEG/PNG streams.
The Fix: Direct Streaming OpenXML ZipArchive
An .xlsx workbook is simply a standard ZIP package containing OpenXML parts. Writing XML strings and raw image streams directly to a ZipArchive with CompressionLevel.NoCompression bypasses the DOM entirely:
using var memoryStream = new MemoryStream();
using (var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
{
// 1. Write foundational OpenXML structural manifests
WriteZipEntry(zip, "[Content_Types].xml", ContentTypesXml(hasImages: photoLookup.Count > 0));
WriteZipEntry(zip, "_rels/.rels", RootRelsXml());
WriteZipEntry(zip, "xl/workbook.xml", WorkbookXml());
WriteZipEntry(zip, "xl/styles.xml", StylesXml());
// 2. Stream worksheet rows directly without intermediate cell objects
WriteWorksheetStreaming(zip, records, columns, photoLookup);
// 3. Write DrawingML anchors and store image bytes directly
if (photoLookup.Count > 0)
{
WriteDrawingRelationships(zip, photoLookup);
// Images are already compressed — NoCompression avoids redundant CPU deflation
WriteImageParts(zip, photoLookup, compressionLevel: CompressionLevel.NoCompression);
}
}
return memoryStream.ToArray();Complementary Image Optimization
Each embedded photo is validated, bounded to thumbnail dimensions, and re-encoded (JPEG for opaque images, PNG for alpha channels) before embedding, preventing oversized raw uploads from ballooning output sizes.
Benchmark Result: Workbook serialization dropped from ~45 minutes → 429 milliseconds (>5,000x faster).
Results & Comparative Benchmarks
Applying all four optimizations eliminated all major scaling cliffs for the ~8,600 record / ~6,700 photo dataset:
Comprehensive Benchmark Summary
Scroll horizontally to see all columns.
| Pipeline Stage | Baseline Runtime | Optimized Runtime | Speedup Multiplier | Optimization Mechanism |
|---|---|---|---|---|
| Paginated API Fetch | 2 min 43 s | 31.2 s | ~5.2x | Bounded fan-out with SemaphoreSlim(8) |
| Enrichment DB Lookup | 2.1 s | 0.9 s | ~2.3x | Distinct key filtering & compiled query |
| Photo DB Lookups | 3 min 00 s | 49.2 s | ~3.7x | Concurrent chunked DbContext queries |
| In-Memory Merging | 7.7 s | 0.005 s | ~1,500x | Pre-computed Dictionary hash indexing |
| Spreadsheet Generation | 40–50 min | 0.429 s | >5,000x | Direct streaming ZipArchive (NoCompression) |
| Total Request Time | ~50 min | ~90 s | ~33x | End-to-End Architectural Pipeline Overhaul |
Architectural Lessons & Best Practices
- Sanitize at the Boundary: Never let unvalidated external inputs (nulls, blanks, duplicates) reach database query translators.
- Profile First, Optimize Second: Measure every stage independently with structured logs before investing effort in optimizations.
- Bound Your Concurrency: Always pair parallel I/O with semaphores to avoid saturating thread pools, sockets, or connection limits.
- Avoid In-Memory DOMs for Binary-Heavy Documents: When writing large documents with thousands of binary parts, stream directly to archive formats.
1. Know Your ORM's SQL Translation Mode
.Contains() against an IQueryable compiles to an Expression Tree, not in-memory evaluation. Understand whether your ORM uses scalar expansion or JSON array parameters, and audit transitive package dependencies to prevent mixed-version behavior.
2. Guard Against O(n²) Iterations
A list.FirstOrDefault(x => x.Id == target) is harmless once, but inside a loop over thousands of records, it creates quadratic complexity. Construct a dictionary or lookup index once before looping.
3. General-Purpose Libraries Are Built for General Use Cases
Full-featured spreadsheet or PDF libraries prioritize versatility over extreme throughput. When profiling reveals that library-internal DOM abstractions consume 85%+ of execution time, writing a narrow, spec-compliant streaming writer unlocks orders of magnitude in speed.
If your application contains bulk export or reporting features, inspect them for sequential network loops, unindexed in-memory joins, or un-throttled database calls before they encounter 10x production data volumes.
Written by Rehan Akbar
Software Engineer · Singapore · .NET, Cloud & Applied AI
I write about the engineering trade-offs behind reliable production systems—Clean Architecture, distributed resilience, observability, and practical AI engineering. Open for architecture advisory and technical collaborations.
Security Should Be Part of Architecture, Not a Final Step
Next ArticleObservability in .NET 10: You Can't Fix What You Can't See
Discussion & Comments
Share your thoughts, ask technical questions, or discuss architecture edge cases.