Skip to content
datahacks.dev
CSV

Parsing CSV in the Browser With Papa Parse Without Blocking

Parse an uploaded CSV in the browser with Papa Parse: typing traps, error objects, encodings, and chunks and workers measured on 500,000 rows.

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

Papa Parse is the CSV parser most people mean when they say "parse CSV in the browser". It handles quoting, detects the delimiter, reads a File straight from an <input type="file">, streams large files in chunks and can run in a worker. It is also configured with enough options that the defaults are worth knowing, since a few of them change your data. This article runs the parser in a real browser on a few small files and a 500,000 row one, shows what comes back, and measures how much of the page's responsiveness each mode costs. The server-side equivalent is in the Node streams article, and the whole subject in the parsing guide.

The basics

papa-tests.jsJavaScript
// Run in the page with papaparse.min.js loaded and the files in work/web served alongside.
async function runPapaBasics() {
  const out = {};
  const people = await (await fetch("people.csv")).text();
  const a = Papa.parse(people, { header: true });
  const b = Papa.parse(people, { header: true, skipEmptyLines: true });
  out.rows = { default: a.data.length, skipEmptyLines: b.data.length, lastDefaultRow: a.data[a.data.length - 1] };
  out.meta = { delimiter: b.meta.delimiter, linebreak: b.meta.linebreak, fields: b.meta.fields };
  out.row3 = b.data[2];
  const zips = await (await fetch("zips.csv")).text();
  out.dynamicTyping = {
    all: Papa.parse(zips, { header: true, dynamicTyping: true, skipEmptyLines: true }).data.map((r) => r.zip),
    selective: Papa.parse(zips, { header: true, dynamicTyping: { id: true }, skipEmptyLines: true }).data.map((r) => r.zip),
  };
  const bad = Papa.parse('id,name\n1,Ana\n2,Bo,extra\n3\n4,"unclosed\n', { header: true });
  out.errors = bad.errors.map((e) => `${e.type}/${e.code} row ${e.row}`);
  out.extraKey = bad.data[1];
  out.detected = Papa.parse("a;b;c\n1;2;3\n").meta.delimiter;
  out.bom = Object.keys(Papa.parse("id,name\n1,Ana\n", { header: true }).data[0]);
  out.crlf = JSON.stringify(Papa.parse("id,qty\r\n1,3\r\n", { header: true, skipEmptyLines: true }).meta.linebreak);
  out.trimmed = Papa.parse("id, name \n1, Ana \n", { header: true, skipEmptyLines: true, transformHeader: (h) => h.trim(), transform: (v) => v.trim() }).data;
  out.unparse = Papa.unparse([{ id: 1, name: "Smith, Jane", note: 'say "hi"' }, { id: 2, name: "Bo", note: null }]).split("\r\n");
  const cp1252 = new File([new Uint8Array([...'id,name\n1,Jos'].map((c) => c.charCodeAt(0)).concat([0xe9, 0x0a, 0x32, 0x2c, 0x5a, 0x6f, 0xeb, 0x0a]))], "upload.csv");
  const read = (opts) => new Promise((res, rej) => Papa.parse(cp1252, { header: true, skipEmptyLines: true, ...opts, complete: (r) => res(r.data.map((x) => x.name)), error: rej }));
  out.upload = { default: await read({}), windows1252: await read({ encoding: "windows-1252" }) };
  return out;
}

runPapaBasics parses the five-line people file (with a quoted comma, doubled quotes and a line break in a note) and several others. Its output:

JavaScript
// in the page, with papaparse.min.js and papa-tests.js loaded
JSON.stringify(await runPapaBasics(), null, 1)
Text
{
 "rows": {
  "default": 5,
  "skipEmptyLines": 4,
  "lastDefaultRow": {
   "id": ""
  }
 },
 "meta": {
  "delimiter": ",",
  "linebreak": "\n",
  "fields": [
   "id",
   "name",
   "city",
   "note"
  ]
 },
 "row3": {
  "id": "3",
  "name": "Bo Chen",
  "city": "Oslo",
  "note": "line one\nline two"
 },
 "dynamicTyping": {
  "all": [
   2134,
   90210,
   501,
   10001
  ],
  "selective": [
   "02134",
   "90210",
   "00501",
   "10001"
  ]
 },
 "errors": [
  "Quotes/MissingQuotes row 4",
  "FieldMismatch/TooManyFields row 1",
  "FieldMismatch/TooFewFields row 2"
 ],
 "extraKey": {
  "id": "2",
  "name": "Bo",
  "__parsed_extra": [
   "extra"
  ]
 },
 "detected": ";",
 "bom": [
  "id",
  "name"
 ],
 "crlf": "\"\\r\\n\"",
 "trimmed": [
  {
   "id": "1",
   "name": "Ana"
  }
 ],
 "unparse": [
  "id,name,note",
  "1,\"Smith, Jane\",\"say \"\"hi\"\"\"",
  "2,Bo,"
 ],
 "upload": {
  "default": [
   "Jos�",
   "Zo�"
  ],
  "windows1252": [
   "José",
   "Zoë"
  ]
 }
}

With header: true, each row becomes an object keyed by the header, and meta.fields lists the column names. The quoted note with a line break came back as "line one\nline two", with the quotes and the break handled. meta.delimiter is , and meta.linebreak is \n, which Papa detected; the same file with CRLF endings reported \r\n.

The rows section shows the first default worth knowing. The people file ends with a newline. Without skipEmptyLines: true Papa returned 5 rows, the fifth being {"id": ""}, a record made of the empty line after the last newline. With it, 4. I set skipEmptyLines on every call, and "greedy" if lines of only whitespace should go too. The same artifact appears as a row of null when dynamicTyping is on.

Types: dynamicTyping and identifiers

dynamicTyping: true converts values that look like numbers or booleans. For the ZIP column of the file used in the other articles:

The dynamicTyping section of the output shows both forms. With true, the ZIP codes came back as 2134, 90210, 501, 10001: the leading zeros are gone, the same trap as in pandas and the other tools in this series. With an object, dynamicTyping: { id: true }, only the named columns are converted, and the ZIPs stayed "02134", "90210", "00501", "10001". Use the object form, and name only columns that are really numbers. For a column of identifiers, phone numbers or anything with a leading zero, text is the correct type. transform and transformHeader give you a hook for the rest; out.trimmed shows transformHeader: h => h.trim() and transform: v => v.trim() cleaning a header of " name ".

Errors are data

Papa Parse does not throw on a malformed row. It reports it in results.errors and carries on. I fed it a four-line file with a row that has an extra field, a row with a missing field, and an unclosed quote at the end:

The errors section lists three: Quotes/MissingQuotes row 4, FieldMismatch/TooManyFields row 1 and FieldMismatch/TooFewFields row 2. The row numbers are positions in the parsed data, not line numbers, and they are not all counted the same way (the unclosed quote on the fourth data row is reported as row 4, the others from zero), and the order is not the file's order (the quote error came first). Do not show them to a user as line numbers without checking them against your own file. The extra value "extra" went into the record under the key __parsed_extra, and the short row had fewer keys. The behavior is convenient for a preview UI and dangerous for an import: if you only look at results.data, you will import the bad rows as if they were fine. Check results.errors.length before you do anything with the data, show the user the errors, and decide on a policy.

Delimiter, BOM and CRLF

Three things Papa does for you, each visible in the output. detected: ";" is the delimiter it guessed for a;b;c with no hint; guessing is a convenience for user uploads, and for a file you control, set delimiter yourself. bom shows the header keys of a file that starts with a byte order mark as ["id","name"]: Papa strips the BOM, which other tools in this series leave glued to the first column name. And crlf shows the line break detected for a Windows file as \r\n, with the rows correct and no stray \r on the last field.

Reading a file from the user

An upload arrives as a File, and Papa can read it directly, in the main thread or a worker. The encoding is the problem to plan for, because a File is bytes:

The last section of the output, upload, parses a small file whose bytes are Windows-1252 (Jos followed by 0xE9). By default Papa reads files as UTF-8, and the invalid byte became the replacement character: "Jos�" and "Zo�". With encoding: "windows-1252" the same bytes read as José and Zoë. The replacement character is permanent damage, since the original byte is gone, so decide the encoding before the text is built. If you do not know it, you have the same detection problem as everywhere, covered in the detection article, and a sensible UI offers the user an encoding choice with a preview of the first rows.

Big files: main thread, chunks and workers

Parsing a string or file of any size on the main thread runs to completion before the browser can paint, scroll or respond to a click. I measured how long the page was blocked for a 500,000 row, 31.7 MB file, by running a 5 ms timer and recording the longest gap between ticks:

papa-tests.jsJavaScript
async function runPapaTimings() {
  const out = {};
  const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("timed out after " + ms + " ms")), ms))]);
  const base = await (await fetch("orders-100k.csv")).text();
  const header = base.slice(0, base.indexOf("\n") + 1);
  const big = header + base.slice(header.length).repeat(5); // 500,000 rows
  const file = new File([big], "big.csv", { type: "text/csv" });
  const ticker = () => {
    let last = performance.now(), gap = 0;
    const id = setInterval(() => { const n = performance.now(); gap = Math.max(gap, n - last); last = n; }, 5);
    return async () => { await new Promise((r) => setTimeout(r, 30)); clearInterval(id); return Math.round(gap); };
  };
  async function measure(name, start) {
    const stop = ticker(), t = performance.now();
    const rows = await start();
    const ms = Math.round(performance.now() - t);
    out[name] = `${rows} rows, ${ms} ms total, longest main-thread stall ${await stop()} ms`;
  }
  await measure("main thread", async () => Papa.parse(big, { header: true, skipEmptyLines: true }).data.length);
  await measure("main thread, chunk 1 MB", () => new Promise((res, rej) => { let n = 0; Papa.parse(file, { header: true, skipEmptyLines: true, chunkSize: 1 << 20, chunk: (r) => { n += r.data.length; }, complete: () => res(n), error: rej }); }));
  await measure("worker, complete only", () => new Promise((res, rej) => Papa.parse(file, { worker: true, header: true, skipEmptyLines: true, complete: (r) => res(r.data.length), error: rej })));
  await measure("worker, step", () => new Promise((res, rej) => { let n = 0; Papa.parse(file, { worker: true, header: true, skipEmptyLines: true, step: () => { n++; }, complete: () => res(n), error: rej }); }));
  await measure("worker, chunk 1 MB", () => new Promise((res, rej) => { let n = 0; Papa.parse(file, { worker: true, header: true, skipEmptyLines: true, chunkSize: 1 << 20, chunk: (r) => { n += r.data.length; }, complete: () => res(n), error: rej }); }));
  try {
    await withTimeout(new Promise((res, rej) => Papa.parse("orders-100k.csv", { download: true, worker: true, header: true, complete: res, error: rej })), 8000);
    out["worker + relative URL"] = "finished";
  } catch (e) { out["worker + relative URL"] = String(e.message); }
  const t = performance.now();
  const r = await new Promise((res, rej) => Papa.parse(location.origin + "/orders-100k.csv", { download: true, worker: true, header: true, skipEmptyLines: true, complete: res, error: rej }));
  out["worker + absolute URL"] = `${r.data.length} rows, ${Math.round(performance.now() - t)} ms`;
  return out;
}
JavaScript
// in the page, with papaparse.min.js and papa-tests.js loaded
JSON.stringify(await runPapaTimings(), null, 1)
Text
{
 "main thread": "500000 rows, 587 ms total, longest main-thread stall 590 ms",
 "main thread, chunk 1 MB": "500000 rows, 458 ms total, longest main-thread stall 23 ms",
 "worker, complete only": "500000 rows, 1009 ms total, longest main-thread stall 215 ms",
 "worker, step": "500000 rows, 672 ms total, longest main-thread stall 71 ms",
 "worker, chunk 1 MB": "500000 rows, 561 ms total, longest main-thread stall 11 ms",
 "worker + relative URL": "timed out after 8000 ms",
 "worker + absolute URL": "100000 rows, 237 ms"
}
  • Main thread: 587 ms total, and the page was unresponsive for 590 ms. For half a million rows it is a visible freeze, and for ten million it is a hung tab.

  • **Main thread with chunk**: 458 ms total and the longest stall was 23 ms. Papa parses a megabyte at a time and returns control between chunks, so the page stays live. It was also faster than the single call in this run.

  • **Worker, complete only**: 1,009 ms total and a stall of 215 ms. Moving the parser off the main thread did not remove the stall, since the worker collects all 500,000 objects and posts them back in one message, and the main thread pays to deserialize them. A worker that returns everything at the end moves the freeze, it does not remove it.

  • **Worker with step**: 672 ms total and a 71 ms stall. A message per row is a lot of messages.

  • **Worker with chunk**: 561 ms total and an 11 ms stall. This was the best: the parser is off the main thread and the results come back in one-megabyte batches.

So the rule is to stream results in chunks, in a worker if you can. Process each chunk as it arrives (validate, aggregate, send to a server) and do not collect the whole array if you do not need it, which also keeps memory flat.

One failure is worth a warning. Parsing a URL with download: true and worker: true did not finish when the URL was relative, "orders-100k.csv": my test gave up after 8,000 ms. With an absolute URL, location.origin + "/orders-100k.csv", it parsed 100,000 rows in 237 ms. I did not diagnose why; the worker is built from a blob, and a relative URL there has no sensible base. Use an absolute URL, or pass a File.

Writing CSV back

Papa.unparse goes the other way. For two objects, one with a comma and a quote and one with a null, it produced id,name,note, 1,"Smith, Jane","say ""hi""" and 2,Bo, (the unparse section of the output): correct quoting and an empty field for the null. The download side, including the BOM for Excel, is in the download article, and a formula-escaping option exists in Papa's unparse configuration that I did not test.

How to tell it worked

Check results.errors and show it. Compare results.data.length with the number of lines you expect, minus the header and any trailing blank. Look at the types of the first row's values, and make sure identifiers are strings. Test with a file that has a comma, a quote and a line break in a field, a BOM, and a Windows-1252 byte. And for big files, watch the page itself: open the browser's performance panel and look for a long task that is longer than a screen refresh.

What I could not test

I ran Chrome only. I did not test Papa.parse with a ReadableStream, with a remote URL that needs credentials, or in a Node environment; the Node streaming API is different and covered in the Node article. I also did not check how it handles a file larger than the browser's memory, for which the answer is chunk and a server. The numbers above are from one machine and one run each, so read them as magnitudes.

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.