Patterns
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
widthvalues instead ofautoWidthon 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