You've undoubtedly faced the same issue if you've ever dealt with Excel creation in.NET: creating a basic.xlsx file requires allocating tens or even hundreds of gigabytes of RAM. Well-known libraries like EPPlus or ClosedXML typically allot 30–100 MB for a 10,000-row dataset with four string columns. Only when a web API is offering file downloads under concurrent load does this issue get worse.
The main methods I employed to create a zero-dependency Excel writer that uses less than 1 MB for the same 10,000-row workload and finishes 8–10× quicker are described in this post. IBufferWriter integration, streaming techniques, OOXML internals, and real-world benchmark comparisons will all be covered.
Why OOXML?
An .xlsx file is just a ZIP archive containing XML files. The structure is well-defined:
[Content_Types].xml– Content type declarations_rels/.rels– Package-level relationshipsxl/workbook.xml– Workbook definition (sheet names)xl/_rels/workbook.xml.rels– Workbook relationshipsxl/styles.xml– Cell stylesxl/sharedStrings.xml– Shared string table (optional)xl/worksheets/sheet1.xml– Sheet dataxl/theme/theme1.xml– Theme definition (optional)
This means we don't need any third-party library to generate a valid .xlsx file. We just need to:
Write XML parts to a ZIP stream
Handle the OPC (Open Packaging Convention) relationship structure
Manage cell data, styles, and shared strings efficiently
Technique 1: Direct-to-Stream Writing
Most Excel libraries build an in-memory DOM first, then serialize it. ClosedXML, for example, builds an entire XLWorkbook object graph with IXLRow, IXLCell, styles, formulas, etc. — all in memory — before writing.
A streaming approach flips this: write XML directly to the output stream as data arrives. No intermediate object model.
Here's the core idea in simplified form:
This pattern eliminates the entire in-memory object graph. The tradeoff is that you lose random access (no modifying cell B4 after writing row 100), but for export scenarios, you never need it anyway.
Technique 2: IBufferWriter<byte> for Low-Allocation Paths
.NET has a lesser-known gem: System.Buffers.IBufferWriter<byte>. It lets you write bytes directly into rented pooled buffers, avoiding intermediate byte[] allocations and copies.
Here's how it works in practice:
The IBufferWriter<byte> path is the primary low-allocation hot path. It chains through ZipArchive → DeflateStream → XML writer, all using IBufferWriter<byte> under the hood, eliminating allocations at every layer.
This is also the technique that System.Text.Json uses under the hood for its high-performance serialization — the same pattern applies to XML generation.
Technique 3: Inline Strings vs. Shared Strings Table (SST)
OOXML supports two ways to store string values:
Inline strings (t="inlineStr"):
The string value lives directly inside the cell element. No shared
strings file needed. This is optimal when strings are mostly unique.
Shared Strings Table (t="s" with index): Strings are stored once in xl/sharedStrings.xml, and cells reference them by index. This is optimal when strings are highly repeated (e.g., status values, categories).
Most libraries default to SST (or build it unconditionally), but SST is wasteful when strings are unique — you double the memory: once for the SST, once for the cell references.
My approach uses a heuristic: pre-scan the first 64 rows. If string deduplication ratio is below 70%, stay with inline strings. Only switch to SST when it actually saves space. This is similar to what Excel itself does when saving files.
Technique 4: Fluent Export Profiles
Rather than a verbose configuration object, a fluent API keeps the code readable and the allocation minimal:
The Action<ExportProfile<T>> callback is compiled into a delegate once (via Expression.Compile or source-gen fast paths with [XlsxExportable]), and the configuration is applied during streaming without allocating intermediate objects.
Technique 5: IAsyncEnumerable<T> for True Streaming
For web API scenarios where data comes from an async source (database, gRPC, event stream), IAsyncEnumerable<T> enables true end-to-end streaming:
This means the HTTP response body stream is written concurrently with the database query. At no point is the full dataset materialized in memory. Contrast this with:
The API is symmetric — reading also uses IAsyncEnumerable:
Note: IAsyncEnumerable reading
streams rows as they're parsed from the ZIP/XWL, but the actual XML
cell parsing is synchronous CPU work. The async benefit is primarily on
the I/O side.
Real-World Benchmarks
All benchmarks run on an Apple M4 with .NET 10, comparing against MiniExcel, ClosedXML, EPPlus, and raw OpenXml SDK. The benchmark code is available in the companion repository.
10,000 rows × 4 string columns
| Library | Mean | Allocated | Ratio |
|---|---|---|---|
| Magicodes.IE.IO | 2.93 ms | 979 KB | — |
| MiniExcel | 11.91 ms | 36,340 KB | 37× |
| EPPlus | 40.22 ms | 29,560 KB | 30× |
| ClosedXML | 50.44 ms | 84,396 KB | 86× |
| OpenXml SDK | 48.96 ms | 28,596 KB | 29× |
10,000 rows × mixed types (string + number + datetime + bool)
| Library | Mean | Allocated | Ratio |
|---|---|---|---|
| Magicodes.IE.IO | 4.99 ms | 1,158 KB | — |
| MiniExcel | 12.97 ms | 37,906 KB | 33× |
| EPPlus | 87.13 ms | 38,400 KB | 33× |
| ClosedXML | 68.26 ms | 102,309 KB | 88× |
Key takeaways
Speed: 4× faster than MiniExcel, 8–10× faster than EPPlus/ClosedXML
Memory: 30–100× less allocation than alternatives
Gen2 GC: Minimal or zero Gen2 collections vs. competitors that trigger Gen2 on every iteration
The 37–88× allocation difference isn't a small optimization — it's the difference between a server that can handle thousands of concurrent exports and one that falls over under GC pressure.
When to Use Each Approach
| Scenario | Recommendation |
|---|---|
| File export, known small data | Any library works; choose based on ease of use |
| Web API file download (HTTP stream) | Streaming approach — avoid materializing byte[] |
| Database → Excel (large datasets) | IAsyncEnumerable + WriteAsync — no full materialization |
| High-concurrency server export | Low-allocation streaming — minimize GC pauses |
| Complex formatting/charts/pivot tables | ClosedXML or EPPlus — they have richer formatting APIs |
| Read-only template generation | Streaming is ideal — you never need to mutate cells post-write |
Limitations
No single approach fits all use cases. The streaming writer has known tradeoffs:
No random access: Once a row is written, you can't go back and modify it. This is fine for export, not for interactive editing.
Reader doesn't evaluate formulas:
Read<T>reads cached values, not formula results.No ZIP64: The underlying ZIP writer doesn't support ZIP64 yet — files must fit within ZIP32 limits (4 GB raw, effectively ~2 GB compressed for large workbooks).
Formatting is output-only: You specify styles declaratively; there's no DOM to programmatically modify after creation.
Conclusion
The
streaming, low-allocation approach to OOXML generation is not
inherently complex — it's just applying well-established .NET patterns (Span<T>, IBufferWriter<T>, IAsyncEnumerable<T>)
to a problem that's traditionally been solved with DOM-style libraries.
The result is an 8–10× speed improvement and 30–100× less memory
allocation, without sacrificing the type safety and fluent API that .NET
developers expect.
The techniques described here — direct-to-stream XML writing, IBufferWriter<byte> pooling, SST heuristics, and IAsyncEnumerable integration — are transferable to any XML-based format and serve as a general template for high-performance .NET I/O.



0 comments:
Post a Comment