Skip to content
Rehan Akbar
Rehan Akbar
All articles
September 22, 2026·14 min
  • #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.

·
14 min read
·
··· reads
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:

  1. An immediate crash with System.InvalidCastException originating from inside Microsoft.EntityFrameworkCore.
  2. Once that was resolved, a 40–50 minute execution duration that tripped reverse-proxy HTTP timeouts and froze browser tabs.
Executive Summary & Performance Impact

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.

Bulk Export Benchmark (~8,600 Rows, ~6,700 Photos, 152 MB XLSX)33x Faster End-to-End
Before~50 Minutes (Timeouts & Stalls)
Optimized~90 Seconds (429ms Excel Writer)

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:

End-to-End Bulk Export Pipeline Stages
1. Paginated IngestionExternal API

Fetches upstream roster records across dozens of paginated REST endpoints.

2. Relational LookupsDatabase I/O

Enriches rows with secondary SIS metadata and binary photo BLOBs.

3. In-Memory MergingCPU & Memory

Correlates student records, custom attributes, and photo bytes via fast indexing.

4. OpenXML StreamingDocument Writer

Emits ZIP parts and DrawingML anchors directly to an output stream without DOMs.

The complete runtime sequence across distributed boundaries is illustrated below:

Architecture Diagram·Mermaid
Rendering architecture diagram...

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:

text
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:

csharp
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:

  1. Unclean Upstream Data: The external REST API returned thousands of records where the majority had null external identifiers. The unvalidated list was passed directly into .Contains(record.ExternalId).
  2. 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 via OPENJSON) was the default in EF Core 8/9 and remains an opt-in mode in EF Core 10.
  3. The Upstream Regression: The project had retained ParameterTranslationMode.Parameter. In EF Core 10.0.2, passing a null element inside a nullable collection to .Contains() under this mode caused ValueConverter.Sanitize<T>() to invoke Convert.ChangeType on a null value, triggering the exact InvalidCastException. This issue is tracked publicly as dotnet/efcore#37537 and resolved in PR #38066.
  4. Mismatched Transitive Packages: A dependency audit revealed mixed package versions across projects (Microsoft.EntityFrameworkCore was patched, but a provider package was pinned to an older build).
Clean Boundary Pattern
  • ✓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
Vulnerable Anti-Pattern
  • ✗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

Architecture Diagram·Mermaid
Rendering architecture diagram...
csharp
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;
}
powershell
# 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:

csharp
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 StageBaseline Runtime% of Total TimePrimary 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:

csharp
// ❌ 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:

csharp
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);
}
Graceful Sliding Window Fallback

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:

csharp
// ❌ 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:

csharp
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 Thread Safety

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:

csharp
// ❌ 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:

csharp
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:

csharp
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:

Architecture Diagram·Mermaid
Rendering architecture diagram...

Comprehensive Benchmark Summary

Scroll horizontally to see all columns.

Pipeline StageBaseline RuntimeOptimized RuntimeSpeedup MultiplierOptimization Mechanism
Paginated API Fetch2 min 43 s31.2 s~5.2xBounded fan-out with SemaphoreSlim(8)
Enrichment DB Lookup2.1 s0.9 s~2.3xDistinct key filtering & compiled query
Photo DB Lookups3 min 00 s49.2 s~3.7xConcurrent chunked DbContext queries
In-Memory Merging7.7 s0.005 s~1,500xPre-computed Dictionary hash indexing
Spreadsheet Generation40–50 min0.429 s>5,000xDirect streaming ZipArchive (NoCompression)
Total Request Time~50 min~90 s~33xEnd-to-End Architectural Pipeline Overhaul

Architectural Lessons & Best Practices

Core Rules for High-Throughput Export Workloads
  1. Sanitize at the Boundary: Never let unvalidated external inputs (nulls, blanks, duplicates) reach database query translators.
  2. Profile First, Optimize Second: Measure every stage independently with structured logs before investing effort in optimizations.
  3. Bound Your Concurrency: Always pair parallel I/O with semaphores to avoid saturating thread pools, sockets, or connection limits.
  4. 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.


Next Steps for Your Architecture

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.

Rehan Akbar

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.

Discussion & Comments

Share your thoughts, ask technical questions, or discuss architecture edge cases.