Repository navigation
feat: Reading wide sheets with mostly-empty styled cells is ~17x slower per cell than dense sheets #302
Description
Summary
iterRows() / loadWorkbook performance degrades sharply when a worksheet has many columns that are empty but carry a style — the common case for real-world business exports (order confirmations, settlement sheets, anything produced by a Chinese operator/telecom billing system).
Measured on a 30,000-row sheet:
| Sheet shape | Throughput per row | Per cell |
|---|---|---|
| 30,000 × 10, every cell populated | 0.62 ms | 6.2 µs |
| 30,000 × 227, ~5 populated per row | 10.51 ms | 46 µs |
Only 0.4% of cells hold a value, yet iteration costs 17x more per cell than a dense sheet. On a production file this is the difference between ~30 s and ~12 minutes.
Reproduction
Synthetic, deterministic, no fixture download needed. The "sparse" sheet mimics the shape produced by these files: every column present in every row, but almost all of them <c r="A4" s="15"/> (style, no value).
import { loadWorkbookStream } from '@office-kit/xlsx/streaming';
import { createWorkbook, addWorksheet } from '@office-kit/xlsx/workbook';
import { appendRows } from '@office-kit/xlsx/worksheet';
import { registerCellStyle, makeAlignment } from '@office-kit/xlsx/styles';
import { workbookToBytes } from '@office-kit/xlsx/io';
const R = 30000;
// 100 distinct styles, as a real workbook would have
const wb = createWorkbook();
const ws = addWorksheet(wb, 'B');
const ids = [];
for (let i = 0; i < 100; i++) {
ids.push(registerCellStyle(wb, {
alignment: makeAlignment({ vertical: 'center', horizontal: i % 3 === 0 ? 'left' : 'center' }),
}));
}
for (let r = 1; r <= R; r++) {
const row = new Array(227).fill(null);
row[0] = r; row[1] = '已起租'; row[3] = '移动';
row[11] = 'S' + r; row[89] = 45250 + (r % 300);
// every column carries a style -> <c r="X" s="n"/>
appendRows(ws, [row], { styleIds: row.map((_, c) => ids[c % ids.length]) });
}
const bytes = await workbookToBytes(wb); // 18.83 MB
const wbRO = await loadWorkbookStream({ toBytes: async () => bytes });
const s = wbRO.openWorksheet('B');
const t = performance.now();
for await (const row of s.iterRows()) { /* no-op */ }
console.log(performance.now() - t); // ~260_000 ms for 30k rowsnode v26.5.0, --max-old-space-size=6144, @office-kit/xlsx@0.24.1, fflate 0.8.3, saxes 6.0.0.
Real-world impact
Orders-20260930150326.xlsx (32.6 MB compressed, sheet1 inflates to 326 MB):
| office-kit | SheetJS 0.18.5 | |
|---|---|---|
| Read (needed sheets only) | 747 s, 254 MB RSS | 199 s, 2.4 GB RSS |
loadWorkbook (whole file) |
784 s, 1.5 GB RSS | 199 s, 2.4 GB RSS |
office-kit wins decisively on memory (10x lower with loadWorkbookStream) and loses 3.8x on time. That trade-off is defensible, but it currently rules the library out of this workflow.
Sheet geometry of that file:
dimension ref="A1:HS4" <- note: incorrect upstream, actual data runs to row 27984
<row> elements: 27,984
<c> elements: 6,352,321 (227 per row)
sharedStrings: 353 (very few strings; mostly empty styled cells)
What I already tried
Column cropping via IterRowsOptions — helps only marginally, because the SAX parser still walks every <c> element; the saving is only in object allocation:
iterRows() 259,754 ms 8.66 ms/row
iterRows({minCol:1, maxCol:98}) 217,381 ms 7.25 ms/row (1.19x)
iterRows({minCol:1, maxCol:14}) 200,920 ms 6.70 ms/row (1.29x)
minRow > 1 band queries behave as documented (build a row index, then query), but they do not address the per-cell cost either.
Likely cause
Worksheet.rows is Map<number, Map<number, Cell>> and the reader allocates a Cell object for every <c> element, including <c r="A4" s="15"/> which carries only a style and no value. So a sheet with 0.4% occupancy still materialises 6.35 M objects, and each one costs a Map insert.
Extrapolating from the numbers above, per-cell cost in the sparse case (46 µs) is ~7x the dense case (6.2 µs), which suggests the style-carrying empty cells are materially more expensive than plain allocation.
Questions / suggestions
- Is there a way to skip materialising style-only empty cells during read — an option on
loadWorkbook/loadWorkbookStreamsuch asskipEmptyCells: true, orcellFilter? The caller only needs the ~6 columns that hold values. - Failing that, could the reader defer the inner
Map(or store a packed row array keyed by column index) so a sparse row costs one array instead of 227 map entries? - Is
dimension ref="A1:HS4"being trusted anywhere? The upstream file declares 4 rows but has 27,984; readers that anchor on<dimension>may silently truncate. Worth confirming this one is ignored rather than trusted.
Not a correctness bug as far as I can tell — getCellExtent reports the true 27984 × 227. It's purely a throughput issue, and it hits exactly the "wide, mostly-empty, heavily styled" sheets that business exports tend to be.
Happy to provide the real file if useful, though it contains customer data — happy to share a scrubbed version instead.
Activity
github-actions commentedon Oct 8, 2026
Hi @lalentur, thanks for the report.
This issue does not match the project's issue template, so it has been auto-closed. Specifically:
<!-- issue-template:bug:v1 -->or<!-- issue-template:feature:v1 -->). Please open this issue from the New issue page so the template is preserved.- [ ]items at the bottom of the template) has been removed. Please restore it.How to fix this
Please edit this issue and restore the template structure. Use the "New issue" page on this repository to start from the official form. When the template is restored, this workflow will reopen the issue automatically.
Why we enforce this
Templates exist so that maintainers can triage efficiently. Issues that omit the template — including reports generated by AI without verification — consume scarce maintainer time without producing actionable signal. Repeated low-effort or AI-slop submissions from the same account may result in being blocked from this repository.
If you believe this auto-close is in error, please leave a comment explaining and a maintainer will take a look.
perf(streaming): speed up iterRows on wide sheets of styled empty cel…