Typed-xlsx
Performance

Patterns

Practical guidance for large exports — builder choice, batch sizing, auto-width, sub-row expansion, and multi-sheet.

Choose the right builder

The first decision is which builder to use. The threshold is not a hard row count — it depends on row shape, column count, and available memory.

A rough rule: if your rows array would exceed ~200 MB in heap (estimate 200 bytes per row × columns as a starting point), use the stream builder.

import { 
createExcelSchema
,
createWorkbook
,
createWorkbookStream
} from "@chronicstone/typed-xlsx";
const
schema
=
createExcelSchema
<{
id
: string;
amount
: number }>()
.
column
("id", {
accessor
: "id" })
.
column
("amount", {
accessor
: "amount" })
.
build
();
// OK for up to ~50–100k rows const
buffered
=
createWorkbook
();
buffered
.
sheet
("Data").
table
({
rows
: await fetchAll(),
schema
});
// Correct approach for 500k+ rows const
stream
=
createWorkbookStream
({
tempStorage
: "file" });
const
table
= await
stream
.
sheet
("Data").
table
({
id
: "data",
schema
});
for await (const
batch
of fetchCursor()) {
await
table
.
commit
({
rows
:
batch
});
} await
stream
.
writeToFile
("./report.xlsx");

Source-aligned batch sizes

Match your commit batch size to your data source's natural page size. Re-batching inside your loop adds overhead without benefit.

import { 
createExcelSchema
,
createWorkbookStream
} from "@chronicstone/typed-xlsx";
const
schema
=
createExcelSchema
<{
id
: string }>().
column
("id", {
accessor
: "id" }).
build
();
const
workbook
=
createWorkbookStream
();
const
table
= await
workbook
.
sheet
("Data").
table
({
id
: "rows",
schema
});
// Database cursor already paginates at 2000 rows — use that directly for await (const
page
of db.query("SELECT * FROM orders").cursor(2000)) {
await
table
.
commit
({
rows
:
page
});
} await
workbook
.
writeToFile
("./report.xlsx");

Auto-width on large exports

autoWidth: true on a column forces the engine to compute column width by scanning all cell values. For very large exports this adds overhead. Set explicit width values on columns where you know the expected content length.

import { 
createExcelSchema
} from "@chronicstone/typed-xlsx";
createExcelSchema
<{
id
: string;
description
: string;
amount
: number }>()
.
column
("id", {
accessor
: "id",
width
: 12 }) // fixed: UUIDs are ~36 chars but truncated
.
column
("description", {
accessor
: "description",
width
: 60,
style
: {
alignment
: {
wrapText
: true } },
}) .
column
("amount", {
accessor
: "amount",
width
: 16,
style
: {
numFmt
: "$#,##0.00" } })
.
build
();

Sub-row expansion on large datasets

Array-valued accessors expand each logical row into multiple physical rows. The expansion happens per batch, so it does not change the memory model fundamentally — but it does multiply your physical row count. A 100k-row dataset where each row has an average of 5 sub-rows produces 500k physical rows in the output.

Account for this when estimating output file size and generation time.

import { 
createExcelSchema
} from "@chronicstone/typed-xlsx";
type
Order
= {
id
: string;
lines
:
Array
<{
sku
: string;
qty
: number }> };
// Each order expands to N rows — one per line item
createExcelSchema
<
Order
>()
.
column
("orderId", {
accessor
: "id" })
.
column
("sku", {
accessor
: "lines",
transform
: (
lines
) =>
lines
.
map
((
line
) =>
line
.
sku
),
}) .
column
("qty", {
accessor
: "lines",
transform
: (
lines
) =>
lines
.
map
((
line
) =>
line
.
qty
),
}) .
build
();

Memory-constrained environments

For Lambda functions, edge workers, or containers with < 512 MB available:

import { 
createWorkbookStream
} from "@chronicstone/typed-xlsx";
const
workbook
=
createWorkbookStream
({
tempStorage
: "memory", // no disk access — important for read-only environments
strings
: "inline", // no shared string table in heap
});

If the container has disk access, prefer tempStorage: "file" to keep heap flat.

Multi-sheet large exports

Each sheet has its own spool. Multiple sheets in a single stream export are written sequentially — the ZIP assembler streams them one by one at finalization. Memory stays flat regardless of the number of sheets.

import { 
createExcelSchema
,
createWorkbookStream
} from "@chronicstone/typed-xlsx";
const
schema
=
createExcelSchema
<{
id
: string }>().
column
("id", {
accessor
: "id" }).
build
();
const
workbook
=
createWorkbookStream
({
tempStorage
: "file" });
// Create all tables up front const
q1
= await
workbook
.
sheet
("Q1").
table
({
id
: "q1",
schema
});
const
q2
= await
workbook
.
sheet
("Q2").
table
({
id
: "q2",
schema
});
const
q3
= await
workbook
.
sheet
("Q3").
table
({
id
: "q3",
schema
});
const
q4
= await
workbook
.
sheet
("Q4").
table
({
id
: "q4",
schema
});
// Commit all sheets — each spool grows independently, no cross-sheet memory coupling await
Promise
.
all
([
(async () => { for await (const
b
of fetchQ1()) await
q1
.
commit
({
rows
:
b
});
})(), (async () => { for await (const
b
of fetchQ2()) await
q2
.
commit
({
rows
:
b
});
})(), (async () => { for await (const
b
of fetchQ3()) await
q3
.
commit
({
rows
:
b
});
})(), (async () => { for await (const
b
of fetchQ4()) await
q4
.
commit
({
rows
:
b
});
})(), ]); await
workbook
.
writeToFile
("./annual.xlsx");

Checklist for large exports

  • Use createWorkbookStream() for datasets that won't fit in memory
  • Set tempStorage: "file" unless disk access is unavailable
  • Set strings: "inline" if heap is the primary constraint
  • Set strings: "shared" if output file size is the primary constraint
  • Use fixed width values instead of autoWidth on wide schemas
  • Commit in batches of 1000–10000 rows (match your source's page size)
  • Avoid re-materializing data between source and commit
  • Account for sub-row expansion when estimating output file size
Copyright © 2026 Cyprien Thao. Released under the MIT License.