SheetJS (xlsx): JavaScript heap out of memory, or RangeError: Invalid string length, writing a large file
SheetJS keeps the whole workbook as objects, one per cell, and then turns
each sheet into a single string of XML before anything is written. Two
limits sit on that path. The heap limit comes first on most machines and
gives you the fatal crash; the string limit comes first on a big one and
gives you the RangeError. Neither is a bug in your loop, and
the package has no streaming .xlsx writer to switch to.
Why it happens
aoa_to_sheet makes a cell object for every value, keyed by its
A1 address, with its type and its value. writeFile then walks
the sheet, builds the XML for every row, joins it into one string, and hands
that to the zip writer. The array you built, the cell objects, the row strings
and the joined sheet all exist at once at the peak.
That works out at roughly 2.2 KB per row of four plain cells. Measured on
Node 22.23 with SheetJS 0.18.5, writing a string, a number, a date string
and a status:
10,000 rows 130 MB peak 0.2s
50,000 rows 229 MB peak 0.7s
200,000 rows 507 MB peak 3.2s
500,000 rows 1,141 MB peak 6.5s
1,000,000 rows 2,204 MB peak 18.8s
Pinned to a 512 MB heap, the size of a modest container, 300,000 rows
completed and 400,000 produced the line everyone searches for first:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
Give it enough memory and a different wall arrives. V8 will not make a
string longer than 229 − 24 characters, about 537 million, and
the sheet XML is one string. Measured at 194 bytes of XML per row of four
cells, that limit lands at roughly 2.7 million rows, or proportionally fewer
with more columns: around 700,000 rows of sixteen. Past it, SheetJS throws
RangeError: Invalid string length from inside the writer, after
having spent several gigabytes getting there. Excel itself stops at
1,048,576 rows a sheet, so that wall is only reachable with wide rows; for
what to do about Excel's own limit, see
the row-limit
page.
Both crashes are fatal to the process, not an exception you can catch and
turn into a 500, and the build is synchronous: every other request on that
Node process waits for the whole export and then dies with it.
Fix 1: raise the heap
node --max-old-space-size=4096 app.js moves the crash rather
than removing it. Memory grows linearly with cells, so every fixed limit has
a row count that exceeds it, and the limit you pick becomes the size of
export a single user can trigger on a shared process. Reasonable for a
known, bounded export; wrong for one that grows with your customers' data.
Fix 2: a writer that streams
SheetJS has no streaming .xlsx writer and its design cannot
easily have one: the sheet is a dictionary of cells until the moment it is
serialised. XLSX.stream.to_csv streams, but only CSV. If the
output has to be a workbook, the streaming option in Node is ExcelJS's
WorkbookWriter, which writes each row into the zip when you
commit it and lets it go:
npm install exceljs
import ExcelJS from "exceljs";
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
filename: "/tmp/invoices.xlsx",
useSharedStrings: false, // the default; on, memory grows with the data again
useStyles: false, // true if you set any cell styles
});
const sheet = workbook.addWorksheet("Invoices");
sheet.columns = [
{ header: "Customer" }, { header: "Amount" }, { header: "Issued" }, { header: "Status" },
];
for await (const invoice of invoices) { // a cursor, not an array
sheet.addRow([invoice.customer, invoice.amount, invoice.issued, invoice.status]).commit();
}
await workbook.commit();
Same data, same machine:
SheetJS writeFile 200,000 rows 507 MB 3.2s
ExcelJS WorkbookWriter 200,000 rows 158 MB 1.0s
ExcelJS WorkbookWriter 1,000,000 rows 344 MB 4.7s (finishes inside a 64 MB heap)
The trade is a second library and a different API, and committed rows are
gone: no going back to set a cell, no totals computed from cells already
written. Column widths and freeze panes go on before the first commit.
Formulas work. Charts are something neither library produces.
Fix 3: move it off the request
Whichever writer you use, a large export should not run on the HTTP
process. Hand it to a worker thread or a queue, write to storage, and send
the client a link, so a slow export is a slow export and not an outage.
What we would do
If the export is a data table and you can add a dependency, use ExcelJS's
streaming writer. That is the honest answer and it costs an afternoon of
rewriting cell addresses into rows.
If you need charts, or the same export has to come out of two services
identically, or you would rather not own the memory problem at all, that is
what Jarrah is for. Post the rows and get back a styled .xlsx
built outside your process.
Write them as NDJSON: a header line describing the workbook, then one line
per row. Use node:https with a pipeline, not fetch:
import { request } from "node:https";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
async function* ndjson(invoices) {
yield JSON.stringify({ sheets: [{ name: "Invoices", columns: [
{ header: "Customer" }, { header: "Amount", style: "money" },
{ header: "Issued" }, { header: "Status" },
] }] }) + "\n";
for await (const invoice of invoices)
yield JSON.stringify([invoice.customer, invoice.amount, invoice.issued, invoice.status]) + "\n";
}
const req = request("https://api.jarrah.sh/v1/jobs", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.JARRAH_KEY}`,
"content-type": "application/x-ndjson",
},
}, (res) => { /* 202 with a job id: poll /v1/jobs/{id}, or take a webhook */ });
await pipeline(Readable.from(ndjson(invoices)), req);
pipeline stops reading the generator while the socket is full,
so the process never holds more than a few rows: 90 MB at 200,000
rows and 93 MB at a million, and it finishes in a 32 MB heap. Node's
fetch with a streamed body does not do that; it pulled a million
rows into memory and died in a 64 MB heap, which is why the sample does not
use it. The jarrah package on npm does exactly this, if you
would rather not write it.
For small exports there is a synchronous endpoint that hands the file
straight back. See the quickstart. It has a
per-plan cell ceiling, so a 200,000-row sheet takes the streaming path above.
What each option costs you
200,000 rows x 4 columns, Node 22.23 with SheetJS 0.18.5 (community edition), a standalone script with no framework. Memory and time are what your own process
spends: the numbers you can reproduce on your machine.
The ExcelJS and Jarrah rows are the same runs as on the ExcelJS page: same machine, same four columns, same day's benchmark directory. Jarrah's figure is what your process spends writing the rows into the request; the workbook is then built on our side, which is a network round trip you are paying for and is not in that column.
Where this is the wrong choice. If your export is a plain data table and you can add a dependency, ExcelJS's streaming writer wins and it is not close: it finishes 200,000 rows in a second inside your process, in a fifth of the memory, with no network. If what you actually need is CSV, SheetJS's own XLSX.stream.to_csv streams and you need nothing else. Jarrah earns its place when you need charts, which neither library produces, when the same export has to come out of a Node service and a Python one identically, or when you would rather not own the memory problem at all.