Skip to content
datahacks.dev
CSV

Generating a CSV Download From JavaScript in the Browser

Build a CSV string, wrap it in a Blob and trigger a download with an object URL. Covers escaping, the Excel BOM, formula guards and how 1,000,000 rows behave.

9 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the JavaScript logo.

"Export to CSV" is a button on a lot of web apps, and it rarely needs a server. The data is already in the page as an array, so the browser can turn it into text, hand the text to the user as a file, and never send it anywhere. The code is short. The details that go wrong are the same ones that go wrong on a server: a comma in a name, a character that Excel does not read as UTF-8, a cell that a spreadsheet treats as a formula. This article builds the download, tests the bytes in a real browser, and measures how it scales. The same writing problem on the server is in the writing article.

The helper

Two functions build the text, and a third offers it:

csv-download.jsJavaScript
// Build a CSV string from an array of objects. Quotes only what needs it.
function csvCell(value) {
  if (value === null || value === undefined) return "";
  let s = value instanceof Date ? value.toISOString() : String(value);
  // text that starts like a formula gets a leading apostrophe; real numbers are left alone
  if (typeof value === "string" && /^[=+\-@\t\r]/.test(s)) s = "'" + s;
  return /[",\r\n]/.test(s) ? '"' + s.replaceAll('"', '""') + '"' : s;
}

function toCsv(rows, columns) {
  const lines = [columns.join(",")];
  for (const row of rows) lines.push(columns.map((c) => csvCell(row[c])).join(","));
  return lines.join("\r\n") + "\r\n";
}

// Offer it as a download. The BOM is only for Excel on Windows.
function downloadCsv(filename, csv, { bom = false } = {}) {
  const blob = new Blob([bom ? "" : "", csv], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.append(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 0);
  return { blob, url };
}

csvCell handles one value. null and undefined become empty; a Date becomes an ISO string; a string that starts like a formula gets an apostrophe; anything that contains a comma, a double quote or a line break is wrapped in quotes with inner quotes doubled. toCsv joins the cells and the lines with \r\n and returns the text, header first, in the column order you pass. downloadCsv is the browser part:

  1. new Blob([...], { type }) wraps the text. A JavaScript string is UTF-16 in memory and a Blob stores UTF-8, so the conversion happens here.

  2. URL.createObjectURL(blob) returns a blob: URL that points to it.

  3. A hidden <a> with href set to that URL and a download attribute is clicked, which the browser treats as "save this", with the file name you gave.

  4. URL.revokeObjectURL releases the memory the URL holds. I defer it with setTimeout(..., 0) so that the click has been processed first.

What the bytes are

The page I tested built four records with the kinds of values that cause trouble, parsed them back, and looked at the Blob:

download-test.jsJavaScript
// Run in the page after csv-download.js and papaparse.min.js are loaded.
async function runDownloadTest() {
  const rows = [
    { id: 1, name: "Ana Silva", note: "likes tea", delta: -5 },
    { id: 2, name: "Smith, Jane", note: 'said "hello"', delta: 2.5 },
    { id: 3, name: "Zoë Müller", note: "line one\nline two", delta: null },
    { id: 4, name: '=HYPERLINK("http://example.test")', note: "-5 degrees", delta: "-5" },
  ];
  const csv = toCsv(rows, ["id", "name", "note", "delta"]);
  const hex = async (blob, n) => [...new Uint8Array(await blob.arrayBuffer()).slice(0, n)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
  const out = {};
  out.lines = csv.split("\r\n");
  const plain = new Blob([csv], { type: "text/csv;charset=utf-8" });
  const withBom = new Blob(["", csv], { type: "text/csv;charset=utf-8" });
  out.firstBytesPlain = await hex(plain, 8);
  out.firstBytesBom = await hex(withBom, 8);
  out.size = { chars: csv.length, plain: plain.size, withBom: withBom.size };
  const bytes = new Uint8Array(await plain.arrayBuffer());
  const i = bytes.findIndex((b, k) => b === 0xc3 && bytes[k + 1] === 0xab);
  out.zoeBytes = [...bytes.slice(i - 2, i + 2)].map((x) => x.toString(16)).join(" ");
  const back = Papa.parse(csv, { header: true, skipEmptyLines: true }).data;
  out.roundTrip = back.map((r) => [r.name, r.delta]);
  let clicked = null;
  const original = HTMLAnchorElement.prototype.click;
  HTMLAnchorElement.prototype.click = function () { clicked = { download: this.download, blobUrl: this.href.startsWith("blob:") }; };
  const { blob } = downloadCsv("people.csv", csv, { bom: true });
  HTMLAnchorElement.prototype.click = original;
  out.anchor = clicked;
  out.blobType = blob.type;
  out.blobFirstBytes = await hex(blob, 4);
  return out;
}
Shell
// in the page, after loading csv-download.js, download-test.js and papaparse.min.js
JSON.stringify(await runDownloadTest(), null, 2)
Text
{
  "lines": [
    "id,name,note,delta",
    "1,Ana Silva,likes tea,-5",
    "2,\"Smith, Jane\",\"said \"\"hello\"\"\",2.5",
    "3,Zoë Müller,\"line one\nline two\",",
    "4,\"'=HYPERLINK(\"\"http://example.test\"\")\",'-5 degrees,'-5",
    ""
  ],
  "firstBytesPlain": "69 64 2c 6e 61 6d 65 2c",
  "firstBytesBom": "ef bb bf 69 64 2c 6e 61",
  "size": { "chars": 177, "plain": 179, "withBom": 182 },
  "zoeBytes": "5a 6f c3 ab",
  "roundTrip": [
    ["Ana Silva", "-5"],
    ["Smith, Jane", "2.5"],
    ["Zoë Müller", ""],
    ["'=HYPERLINK(\"http://example.test\")", "'-5"]
  ],
  "anchor": { "download": "people.csv", "blobUrl": true },
  "blobType": "text/csv;charset=utf-8",
  "blobFirstBytes": "ef bb bf 69"
}

Read the lines first. The header is id,name,note,delta. Smith, Jane is quoted. said "hello" is written "said ""hello""", the CSV way. The Zoë Müller record has a line break inside its note, so the note is quoted and spans two physical lines. The fourth record's name begins with = and is written "'=HYPERLINK(""http://example.test"")", an apostrophe in front of the formula, and the string -5 degrees got one too.

The bytes: a Blob of the text alone starts 69 64 2c 6e 61 6d 65 2c, which is id,name,. A Blob made from ["", csv] starts ef bb bf 69 64 2c 6e 61: the three bytes of the UTF-8 byte order mark, then the same text. The sizes show it: 177 characters, 179 bytes without a BOM (two characters, ë and ü, take two bytes each) and 182 with it. The bytes for Zoë are 5a 6f c3 ab, so ë is the two-byte UTF-8 sequence c3 ab. That settles an assumption: you do not choose the encoding of a Blob built from a string, it is always UTF-8, and the question is only whether to add the BOM.

The BOM is for Excel on Windows, which is widely reported to read a UTF-8 file without one as a legacy code page and show Zoë. I could not test Excel here. Other tools (Python, DuckDB, a database import) either ignore the mark or are bitten by it, as the BOM article explains, so make it an option, as downloadCsv does with { bom: true }, and offer it where the user will open the file in a spreadsheet. The Excel side is in the UTF-8 in Excel article.

The anchor line shows what the helper did: the anchor had download set to people.csv and an href that began with blob:. I replaced click with a function that recorded those two things, so this verifies the wiring and not the browser's save dialog, and I did not check a downloads folder. blobType is text/csv;charset=utf-8, and blobFirstBytes is ef bb bf 69, the BOM and i.

The formula guard

A spreadsheet treats a cell that begins with =, +, - or @ as a formula, so a customer who typed =HYPERLINK("http://...", "click") into a name field can put a live formula into your export. The guard in csvCell adds an apostrophe, which spreadsheets show as an ordinary text marker. Two details from the output:

  • The round trip through Papa Parse returned '=HYPERLINK("http://example.test") for the name: the apostrophe is part of the data now. That is the cost of the guard. It changes the value, so it belongs on a file for people and not on one a program will read back.

  • The guard is applied to strings only. The number -5 was written -5, and the string "-5" was written '-5. An earlier version I tried tested every value, and turned a legitimately negative number into text. Test the type, not just the first character.

How it scales

The cost of an export is the string, the Blob and the memory for both, and the click is free. I built generated records of four columns, one in fifty with a quoted comma, and timed the two steps:

Shell
// time toCsv() and new Blob() for generated rows (id, name, amount, note), in the page
for (const n of [100000, 500000, 1000000]) { /* build rows, time toCsv, time new Blob */ }
Text
rows       toCsv    new Blob   size
100,000     37 ms     6 ms      3.0 MB
500,000    215 ms    25 ms     15.4 MB
1,000,000  363 ms    51 ms     31.0 MB

A million rows built in 363 ms and became a 31.0 MB Blob in 51 ms. The numbers are small because the work is string concatenation, and Chrome handles it well. The limit you will meet first is memory: the array of objects, the CSV string and the Blob exist at once, and a mobile browser has less room than a desktop one. For exports beyond a few hundred thousand rows, or on phones, I would generate on the server and stream it, with the Content-Disposition header, instead of building it in the page. The File System Access API can write a file in chunks from the browser, but it is not available in every browser and I did not test it.

Reading it back

A generated file should round-trip. Papa Parse, which the browser parsing article covers, read the text with header: true and skipEmptyLines: true and returned the same names and values, with the Zoë Müller row's empty delta as "" and the line break inside the quoted note intact. That is the cheapest test to leave in a project: build the CSV from rows with the awkward values, parse it, compare.

How to tell it worked

Open the downloaded file in a text editor that shows hidden characters and check the first bytes and the line endings. Open it in the spreadsheet your users have. Put a name with a comma, a quote and an accent in the test data. Click the button twice and confirm that the second download does not fail, which it would if a revoked URL was reused. And check the file size against what you expected; an empty export with only a header is what a typo in a column name produces.

What I could not test in a browser

I did not test other browsers, the real save dialog, large exports on a phone, or Excel. If the export matters, test it where your users are. And the escaping and the formula guard are only as good as the cases in the test: a CSV writer that has never been given a quote has not been tested. For the reverse direction, uploading and reading a user's CSV, see the parsing guide, and if the downloaded file arrives with the wrong characters, the garbled characters article.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.