Design a file uploader
A reusable upload component like the ones in Google Drive, Gmail, Slack and Dropbox: pick, drop, paste or choose a folder, check each file before it leaves the device, show previews, and send many files at once with progress, pause, cancel and retry. The hard part is that every file is a small long-running job that can fail halfway, and a 4 GB video on hotel Wi-Fi must survive a dropped connection and even a page reload.
The question
Design a file upload component for our design system. Product teams will use it for email attachments, for a shared files page, and for the image button in our chat composer.
People should be able to choose files, drag them in, or paste them. Show a preview and progress for each file, let people cancel and retry, and make large files work on slow networks. Walk me through the component, how files travel to the server, and the API other teams will use.
Real-life examples: Uploading files and folders in Google Drive, Adding attachments to an email in Gmail, Dropping files into the message composer in Slack, The Dropbox web uploader, Attaching images to a GitHub issue by pasting them
Requirements: one component, three very different jobs
An uploader takes files from the person's device and sends them to a server. In Gmail you click the paper clip and a chip with the file name and a thin progress bar appears under the message. In Slack you drop a screenshot onto the composer and a small preview card shows up before you press send. In Google Drive you drop a whole folder and a panel in the corner lists 200 files ticking from 0 to 100 percent.
Those three look alike, but they stress very different parts of the design. A Gmail attachment is usually a few MB, a Drive folder can hold thousands of files and single videos of several GB, and a Slack paste is one image that must feel instant. The interview question asks for one component that serves all of them, so the first job is to find out which of these limits we must hit.
Questions to ask
Each question below changes a real decision later in the guide. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How big can one file be? | Up to 5 GB on the files page, 25 MB for email attachments. | Anything over about 100 MB needs chunked, resumable uploads. Small files can go in one request. |
| How many files at once? | Up to 1,000 in one drop on the files page, 10 in the composer. | We need a queue with a concurrency limit, not one request per file all at once. |
| Which ways of adding files? | File picker, drag and drop, paste, and whole folders on the files page. | Four input paths that all feed the same list. |
| Which file types? | Each product decides: images only for avatars, anything for files. | Validation rules are props, not hard-coded. |
| Where do the bytes go? | Straight to object storage (S3 or GCS) with short-lived signed URLs. | Our app server never carries the bytes; it only signs and records. |
| What happens after 100 percent? | The server scans for viruses and makes thumbnails, which takes seconds. | A visible "processing" state after the upload ends. |
| Must uploads survive a reload or a closed laptop lid? | Yes for large files: resume where they stopped. | Upload ids and progress saved in IndexedDB. |
| Which devices and networks? | Desktop and mobile web, down to a slow 3G phone and flaky hotel Wi-Fi. | Retries with backoff, small enough chunks, and honest time estimates. |
Functional requirements
- Add files with a labelled button that opens the system picker, by dropping them, by pasting from the clipboard, and (where allowed) by choosing a folder.
- Check each file before upload (type, size, count, and pixel dimensions for images), show a clear message on the exact file that failed, and keep the good ones.
- Show a row per file with its name, size, an image preview, a progress bar, and its state: waiting, uploading, paused, failed, processing, done or cancelled.
- Upload several files in parallel, but only a few at a time, with progress per file and for the whole batch.
- Pause, resume, cancel and retry each file, with the mouse, the keyboard and a screen reader.
- Resume a large upload after a network drop or a page reload, without starting again from byte zero.
- Give the host app the list of files and, for each finished one, the server id it needs to attach the file to an email, a message or a folder.
Non-functional requirements
| Quality | Target | Why this number |
|---|---|---|
| Feedback after a drop | Rows appear within 100 ms, even for 1,000 files | People need to see the drop worked. 100 ms feels instant. |
| Main thread | No task over 50 ms while making previews | Long tasks make the page stutter and hurt Interaction to Next Paint. |
| Throughput | Use most of the uplink: a 20 Mbps uplink should move a 1 GB file in about 7 minutes | 1 GB is 8,000 megabits; 8,000 divided by 20 is 400 seconds. |
| Resilience | A dropped connection loses at most one chunk of work (8 MiB here) | Restarting a 4 GB upload at 90 percent is the failure people remember. |
| Memory | Never read a whole large file into memory | A 4 GB ArrayBuffer crashes a phone tab. Read slices on demand. |
| Accessibility | Every action works without a mouse; progress is announced, but not every 1 percent | Drag and drop is a mouse-only gesture; screen reader users need a real button. |
| Security | The server re-checks everything; uploaded files never run as code on our origin | Client checks are for speed and kindness, not for safety. |
Out of scope
- Editing files before upload (cropping an avatar is its own problem: see the image cropper design).
- Syncing a folder on disk the way the Dropbox desktop app does. A web page can't watch your disk.
- The storage backend itself: how S3 stores parts, how the virus scanner works. We only design the contract with them.
- Sharing and permissions on uploaded files. That belongs to the files product, not the uploader.
Check your understanding · Exercise 1
Which answers from the interviewer change the design the most? Select all that apply.
What makes an uploader hard
A first version takes ten lines: an input type="file", a FormData, one fetch to /upload. It works on the developer's fast office network with a 2 MB test image. Then it meets real people, and five properties of the problem show up one after another. Every later chapter answers one of them.
- Files come from many doors. A picker, a drag from the desktop, a screenshot in the clipboard, a folder with 40 subfolders. Each door hands you files in a different shape (
FileList,DataTransferItemList, file system entries), with its own browser quirks. - Files are untrusted and often wrong. A 300 MB video in an avatar field,
report.exerenamed toreport.pdf, an iPhone HEIC photo that Chrome can't display. Catch honest mistakes early and kindly, and still treat every byte as hostile on the server. - Each file is a long-running job. A 2 GB upload on a 10 Mbps uplink takes about 27 minutes, during which it can be paused, cancelled, fail at 61 percent, wait for the network, or finish and wait for a virus scan. Bugs live in the transitions between those states.
- Many jobs share one pipe. Dropping 500 photos must not open 500 connections: too many in parallel and nothing finishes, too few and you waste the uplink.
- Networks fail mid-upload, and people leave. Phones switch towers, laptops sleep, tabs get closed. Without a way to continue from the last good byte, every failure throws away all the work so far.
Notice what is not on the list: drawing a progress bar. The design work is in the pipeline behind it, which takes a file from a drop event to a validated item, a queued job, bytes on a storage server, and a record the host app can use.
Files enter through one of four doors and become one shape, an upload item. Validation marks each item accepted or rejected, and accepted items join a queue that starts at most three at a time. Each started item asks the server where to send its bytes, sends them (whole or in chunks), and waits while the server processes the file. Every stage can fail, and every failure must land the item in a state the person can see and act on.
Think first
The ten-line version sends all files in one FormData with one fetch. Before reading on, list three things that go wrong when someone drops 200 photos of 4 MB each on a 5 Mbps uplink.
The request is 800 MB, which at 5 Mbps takes about 21 minutes, and fetch gives no upload progress, so the person stares at a spinner.
If anything fails at minute 20, all 200 files fail together and the retry sends all 800 MB again. There's no per-file success.
A proxy or the server probably rejects a body that large (many default limits sit between 1 MB and 100 MB), and holding the request open that long ties up a server worker.
There's no way to cancel one file, no previews, and no check that the photos were photos before spending 21 minutes sending them.
Getting files in: picker, drop zone, paste and folders
Start with the door everyone can use: a real input type="file". It opens the operating system's picker, works with a keyboard and a screen reader, and on phones it offers the camera and the photo library.
Two attributes matter. multiple lets people pick more than one file, and accept (for example image/*,.pdf) filters the picker so the wrong files are greyed out. accept is only a hint: many pickers have an "All files" switch and drops ignore it, so it never replaces validation.
The native input is hard to style, so the usual pattern is a styled button that calls input.click() on a hidden input. Keep the input in the page but out of sight and out of the tab order (the accessibility chapter lists the attributes), so keyboard focus lands only on the button. Give the button a clear name like "Add files", and reset input.value = "" after reading the files, or picking the same file twice in a row fires no change event.
Decision How should the component treat drag and drop compared with the button?
What we pick: Button first, with the drop zone as an enhancement. A dropTarget prop picks the area: the component's own box by default, or "page" for the Slack and Drive style overlay on the files page. Every door ends in the same addFiles(files, source) call, so validation and the queue never care where a file came from.
Drag events and the flicker problem
Four events matter: dragenter, dragover, dragleave and drop. Two rules trip everyone up. First, the browser's default for a dropped file is to open it and navigate away from your app, so you must call preventDefault() in both dragover and drop.
Second, dragleave fires every time the pointer moves from the drop zone onto one of its children (the icon, the text), and dragenter fires on the child. If you toggle a highlight on enter and leave, it flickers off and on as the pointer crosses the text.
dragenter on the new element before dragleave on the old one, so crossing a child sends a leave while the pointer is still inside. Counting enters and leaves keeps the highlight on until the count is back to 0.Three fixes are common. A depth counter adds one on every dragenter and subtracts one on every dragleave; the zone is active while the counter is above zero. Checking event.relatedTarget (the element the pointer moved to) and ignoring a leave into a child also works, but relatedTarget is null in some browsers for drags coming from outside the window.
Setting pointer-events: none on the zone's children during a drag removes the child events entirely. The counter is the most portable, but a cancelled drag (Escape, or a drop outside the zone) can leave its enters and leaves unbalanced. So reset it on drop, and add a safety net: browsers keep firing dragover every few hundred milliseconds while a drag sits over the zone, so clear the highlight when those events stop for about a second.
function useDropZone(onFiles: (items: DataTransferItemList) => void) {
const depth = useRef(0);
const [active, setActive] = useState(false);
const hasFiles = (e: DragEvent) => e.dataTransfer?.types.includes("Files");
return {
active,
onDragEnter(e: DragEvent) {
if (!hasFiles(e)) return; // ignore dragged text or links
depth.current += 1;
setActive(true);
},
onDragOver(e: DragEvent) {
if (!hasFiles(e)) return;
e.preventDefault(); // allow the drop
e.dataTransfer!.dropEffect = "copy";
},
onDragLeave(e: DragEvent) {
if (!hasFiles(e)) return;
depth.current -= 1;
if (depth.current <= 0) { depth.current = 0; setActive(false); }
},
onDrop(e: DragEvent) {
e.preventDefault(); // don't let the browser open the file
depth.current = 0;
setActive(false);
onFiles(e.dataTransfer!.items);
},
};
}
During a drag you can't read file names or sizes, for security reasons. You know that types contains "Files" and, in most browsers, how many items there are and their MIME types. So the zone can say "Drop 3 files", but not "report.pdf is too big" until the drop happens.
Paste from the clipboard
Screenshots are the most pasted thing in chat apps. Listen for the paste event on the composer (not on document, or every paste on the page becomes an upload) and read event.clipboardData.files. A pasted screenshot usually arrives as a File named image.png, so give it a better name, such as Screenshot 2026-10-20 at 14.05.png: ten rows called "image.png" are useless.
Some apps put both text and an image on the clipboard. Then let the text paste normally and take the files only when there is no text, or ask, as the rich text editor design does for pasted images.
Folders
The picker way to get a folder is input type="file" webkitdirectory: the person picks a folder and gets a flat FileList where every file has webkitRelativePath, like trip/day1/IMG_0042.jpg. Despite the prefix, desktop Chrome, Edge, Firefox and Safari all support it. On phones support is recent or missing (iOS Safari added it in 18.4), so feature-detect it and hide the "Upload folder" button where it's missing. Chrome also asks the person to confirm before it hands a folder to the page ("Upload 1,240 files to this site?").
The drop way is DataTransferItem.webkitGetAsEntry(), supported in all major browsers. It returns a FileSystemEntry that is either a file or a directory, and you walk directories with createReader().readEntries().
Two traps. readEntries returns entries in batches (Chrome returns at most 100 per call), so call it in a loop until it returns an empty array. And call webkitGetAsEntry on every item synchronously inside the drop handler, because the DataTransferItemList is emptied once the handler returns. Chromium also has getAsFileSystemHandle(), which returns a File System Access handle you can keep, but it is Chromium only, so treat it as an extra, not the base.
type Dropped = { file: File; path: string };
async function filesFromDrop(items: DataTransferItemList): Promise<Dropped[]> {
const entries = [...items]
.map((item) => item.webkitGetAsEntry()) // synchronous, inside the drop event
.filter((e): e is FileSystemEntry => e !== null);
const out: Dropped[] = [];
async function walk(entry: FileSystemEntry, path: string) {
if (entry.isFile) {
const file = await new Promise<File>((ok, fail) => (entry as FileSystemFileEntry).file(ok, fail));
out.push({ file, path: path + file.name });
return;
}
const reader = (entry as FileSystemDirectoryEntry).createReader();
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((ok, fail) => reader.readEntries(ok, fail));
if (batch.length === 0) break; // readEntries returns in batches
for (const child of batch) await walk(child, path + entry.name + "/");
}
}
for (const entry of entries) await walk(entry, "");
return out;
}
Keep the relative path on the upload item, because the server needs it to recreate the folder tree. Also skip hidden system files such as .DS_Store and Thumbs.db: nobody wants them in a shared folder.
Check your understanding · Exercise 2
A drop zone works in testing, but in production some folder drops only upload the first 100 files, and the highlight sometimes stays on after a drag. What are the likely causes? Select all that apply.
Checking files before they leave the device
Client-side validation has one purpose: save people time. Telling someone within 50 ms that their 80 MB video won't fit a 5 MB avatar field is much kinder than letting it upload for four minutes and then fail. It is not a security measure: anyone can call your upload endpoint with curl, so the server repeats every check.
The rules come from the host product as props, because they differ per use: the avatar field wants one image of at most 5 MB and at least 200 by 200 pixels, Gmail-style attachments want up to 25 MB in total, the files page wants anything up to 5 GB. The uploader runs them in order from cheapest to most expensive and stops at the first failure for each file.
| Check | How | Cost | Example message |
|---|---|---|---|
| Count | Existing items plus new ones against maxFiles | Free | "You can attach up to 10 files. 3 were not added." |
| Size | file.size against maxSize; also a total for the batch | Free | "holiday.mov is 1.2 GB. The limit is 25 MB." |
| Empty file | file.size === 0 | Free | "notes.txt is empty." |
| Type by extension and MIME | file.name ending and file.type against accept | Free | "setup.exe isn't a supported type. Use PDF, PNG or JPG." |
| Type by content | Read the first bytes and compare with known signatures | One small read, about 1 ms | "photo.jpg doesn't look like a JPG file." |
| Image dimensions | Decode the image header | A few ms to 100+ ms for big photos | "logo.png is 120 by 80 pixels. Use at least 200 by 200." |
| Duplicates | Same name, size and lastModified already in the list | Free | "report.pdf is already in the list." |
Extension, MIME type and magic bytes
file.type looks authoritative but it isn't read from the file. The browser guesses it from the extension using the operating system's table. Rename virus.exe to cat.png and file.type says image/png. For files with unusual extensions it's often the empty string, so a check like file.type === "application/zip" rejects perfectly good files on some systems.
Most file formats begin with a fixed signature, called magic bytes. A PNG starts with the 8 bytes 89 50 4E 47 0D 0A 1A 0A, a JPEG with FF D8 FF, a PDF with the text %PDF, a GIF with GIF8, and a ZIP (which includes .docx and .xlsx) with 50 4B 03 04. Reading the first few bytes with file.slice(0, 16).arrayBuffer() costs about a millisecond and catches renamed files and corrupted downloads before upload.
const SIGNATURES: { type: string; bytes: number[] }[] = [
{ type: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ type: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
{ type: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
{ type: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] },
{ type: "application/zip", bytes: [0x50, 0x4b, 0x03, 0x04] },
];
async function sniffType(file: File): Promise<string | null> {
const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
const match = SIGNATURES.find((sig) => sig.bytes.every((b, i) => head[i] === b));
return match?.type ?? null; // null: unknown, fall back to the extension
}
Decision How strict should the client's type check be?
What we pick: Extension and MIME, with magic bytes for the formats the product cares about. It costs almost nothing and turns "Upload failed" four minutes later into "This file isn't really a PNG" right away. The real decision about what a file is happens on the server, which sniffs the content again and ignores the client's Content-Type.
Image dimensions without blocking the page
createImageBitmap(file) decodes an image and gives its width and height. It is asynchronous, so the page isn't blocked, but it decodes the whole image: a 48 megapixel phone photo becomes about 190 MB of pixels (48 million times 4 bytes) until you call bitmap.close().
For a dimension check alone, reading the file header is cheaper (a PNG stores width and height at bytes 16 to 23, in its IHDR chunk). Many teams still use createImageBitmap, because the same decode feeds the thumbnail in the next chapter. Check at most two or three files at a time, so a drop of 300 photos doesn't decode 300 photos at once.
Messages that help
Reject per file, not per batch: if 2 of 30 files fail, add the 28 good ones and show the 2 bad ones as rejected rows with a reason and a remove button. Each message names the file, the rule and the limit, and says what to do: "holiday.mov is 1.2 GB. Files can be up to 25 MB. Share large files with a Drive link instead."
Gmail does something similar, offering a Google Drive link for attachments over 25 MB. Format sizes with Intl.NumberFormat and the unit style so "1.2 GB" reads right in every locale, and use the same base (1,000 or 1,024) as the server's limit. Otherwise a file the client shows as "25.0 MB" (counted in 1,024s) fails a server limit of 25,000,000 bytes, and nobody understands why.
Check your understanding · Exercise 3
An avatar field accepts PNG and JPG up to 5 MB. Which client checks are worth doing before upload? Select all that apply.
Previews: showing a file before the server has it
A preview is what makes an uploader feel fast. In Slack, the screenshot you paste shows up as a card before a single byte has reached the server. The bytes are already on the device, so there's no reason to wait. The question is how to turn a File into pixels without wasting memory or freezing the page.
The tool is URL.createObjectURL(file), which returns a short string like blob:https://app.example.com/6f1c... that points at the file; put it in an img or video src, and nothing is copied or encoded. The catch: the browser keeps the file alive for as long as the URL exists, which is until you call URL.revokeObjectURL(url) or the page unloads. In a long-lived app like Gmail, a composer that creates URLs and never revokes them leaks every attached image for the life of the tab.
Decision How should the uploader draw image thumbnails?
What we pick: A worker with createImageBitmap, at most two thumbnails in flight, newest drop first. Each thumbnail blob gets its own object URL, revoked when the row is removed or the component unmounts. For a handful of small images (the chat composer case) the plain object URL is fine, so the component starts with that and switches to worker thumbnails for files over about 2 MB or batches of more than 10 images.
self.onmessage = async (e: MessageEvent<{ id: string; file: File }>) => {
const { id, file } = e.data;
try {
const bitmap = await createImageBitmap(file, {
resizeWidth: 128,
resizeQuality: "medium",
imageOrientation: "from-image", // respect the camera's EXIF rotation
});
const canvas = new OffscreenCanvas(128, Math.round((128 * bitmap.height) / bitmap.width));
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height); // also scales if resize was ignored
bitmap.close(); // free the pixels now, not at GC time
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.8 });
self.postMessage({ id, blob });
} catch {
self.postMessage({ id, blob: null }); // undecodable here: show a file icon
}
};
Passing the File to the worker is cheap: postMessage copies a reference to the file, not its bytes. When resizeWidth alone is given, the height keeps the aspect ratio. The main thread receives the small blob, makes an object URL, and stores it on the item.
Sideways photos and iPhone HEIC files
Phone cameras save pixels in the sensor's direction and write the real orientation into EXIF metadata. Since 2020, all major browsers apply it by default when they draw an image (the CSS image-orientation: from-image default), so an img preview is upright without the hand-written rotation code uploaders once needed. For createImageBitmap, pass imageOrientation: "from-image" explicitly as the code does, because older engines treated its default differently.
iPhones save photos as HEIC by default. Safari 17 and later can display HEIC; Chrome and Firefox can't. iOS often converts photos to JPEG when a page asks for them through a file input, depending on the accept list, but you can't count on it, and a HEIC can also arrive from a Mac or a shared drive.
So decide from the magic bytes (a HEIC file has ftyp at byte 4, followed by a brand such as heic), try to decode, and when decoding fails show a generic photo icon with the name instead of a broken image. The server makes a JPEG or WebP version for browsers that can't show HEIC.
Video, PDF and everything else
For a video, an object URL in a muted video element with preload="metadata" gives the duration and dimensions; drawing the frame at one second on a canvas gives a poster, so do it only for visible rows. A PDF page needs a library like PDF.js (several hundred KB), so most uploaders show a type icon and let the server make the real thumbnail. The rule: the client makes previews only when the browser can do it cheaply and natively; everything else waits for the server's thumbnail.
bitmap.close().Check your understanding · Exercise 4
A Gmail-like composer shows image attachments as previews. After a day of use, the tab uses 2 GB of memory. Which changes fix it? Select all that apply.
The upload queue: a state machine per file
Once a file passes validation it becomes a job. The naive way to track a job is a few booleans (isUploading, isDone, hasError, isPaused), which allow 16 combinations, most of them nonsense: uploading and done, paused and failed. Many uploader bugs, like a row stuck at "99 percent" with a retry button showing, come from those combinations. A state machine fixes it: one status field with a small set of values, and a table of which event moves which status to which.
status field, seven values, and a short list of arrows. Any event without an arrow from the current status is ignored, so a late progress event can't revive a cancelled upload.| Status | Means | Leaves on | Goes to |
|---|---|---|---|
queued | Valid, waiting for a free slot | slot free / cancel | uploading / cancelled |
uploading | Bytes are moving | all bytes sent / error / pause / cancel | processing / failed or queued (auto retry) / paused / cancelled |
paused | Stopped by the person; a chunked upload keeps its finished parts | resume / cancel | queued / cancelled |
failed | Gave up after retries, or a permanent error | retry / remove | queued / removed |
processing | Server has the bytes, scanning and making thumbnails | server says ready / server rejects | done / failed |
done | Ready; has a server id | remove | removed (and deleted on the server if the host asks) |
cancelled | Stopped for good; server told to discard partial data | remove | removed |
Two details make the table work. First, a transient error (a timeout, a 503, the network going offline) sends the item back to queued with an attempt counter and a retryAt time, so it waits its turn with backoff. A permanent error (413 Payload Too Large, 415, the virus scanner says no) goes straight to failed, because trying again can't help.
Second, every transition is checked: an event that isn't allowed in the current status is ignored. So a late progress event from a request that was just cancelled can't drag the item back to uploading.
type Status = "queued" | "uploading" | "paused" | "failed" | "processing" | "done" | "cancelled";
type Event = { type: "START" | "SENT" | "PAUSE" | "RESUME" | "RETRY" | "CANCEL" }
| { type: "PROGRESS"; sent: number } | { type: "ERROR"; retryable: boolean }
| { type: "READY"; fileId: string } | { type: "REJECTED"; reason: string };
function next(item: UploadItem, e: Event): UploadItem {
const s = item.status;
if (s === "queued" && e.type === "START") return { ...item, status: "uploading" };
if (s === "uploading") {
if (e.type === "PROGRESS") return { ...item, sent: e.sent };
if (e.type === "SENT") return { ...item, status: "processing" };
if (e.type === "PAUSE") return { ...item, status: "paused" };
if (e.type === "ERROR")
return e.retryable && item.attempts < 5
? { ...item, status: "queued", attempts: item.attempts + 1, retryAt: Date.now() + backoff(item.attempts) }
: { ...item, status: "failed" };
}
if (s === "paused" && e.type === "RESUME") return { ...item, status: "queued", retryAt: 0 };
if (s === "failed" && e.type === "RETRY") return { ...item, status: "queued", retryAt: 0, attempts: 0 };
if (s === "processing" && e.type === "READY") return { ...item, status: "done", fileId: e.fileId };
if (s === "processing" && e.type === "REJECTED")
return { ...item, status: "failed", error: { code: "rejected", message: e.reason, retryable: false } };
if (e.type === "CANCEL" && (s === "queued" || s === "uploading" || s === "paused"))
return { ...item, status: "cancelled" };
return item; // not allowed in this status: ignore
}
How many at once
Decision How many files should upload at the same time?
What we pick: A fixed limit of 3 by default, as a prop. Even where HTTP/2 removes the connection limit, the uplink is shared, and 3 streams fill a typical home uplink while each file still finishes in reasonable time. A large file sends 3 chunks in parallel and takes all 3 slots, so a 4 GB video doesn't share the pipe with 200 thumbnails and slow both.
The scheduler is a small loop that runs after every state change: while fewer items than the limit are uploading, start the next queued item whose retryAt has passed. The order is first in, first out, with two tweaks: files the person retried by hand jump to the front, because they're watching, and in a chat composer the image the next message will send can go first. "Pause all" pauses the running items and sets the limit to 0 until the person resumes.
Per-file and overall progress
Overall progress must be weighted by bytes, not by file count. With one 1 GB video and nine 100 KB images, a count-based bar jumps to 90 percent in seconds and then sits there for ten minutes. Bytes give the honest answer: total sent divided by the total size of all items still in the batch. Rejected and cancelled files leave the total; failed ones stay in it, so the bar doesn't jump forward when a file breaks.
Try the queue below. Change the concurrency limit, pause and cancel files, and switch on the flaky network to see transient errors go back to the queue with backoff while a permanent error goes to failed. Every file here is small enough for a single PUT, so an error or a pause restarts that file from zero; the chunked lab later shows how chunks keep their work.
0 of 7 files done.
Check your understanding · Exercise 5
An upload hits a 503 Service Unavailable at 40 percent, then the person clicks Cancel while it waits to retry. What should happen? Select all that apply.
Transport: where the bytes go and how we watch them
Now an item is uploading. Two questions remain: which server receives the bytes, and which browser API sends them so we can show progress and cancel. Both have a common naive answer that falls over at scale.
Decision Should the bytes go through our app server or straight to object storage?
What we pick: Pre-signed URLs straight to storage. The three-step shape is common in public APIs too: Slack's Web API hands out a one-time upload URL with files.getUploadURLExternal and records the file with files.completeUploadExternal. Our API stays the gatekeeper: it decides who may upload what and how big, signs a URL that expires in about 15 minutes, and confirms the object exists before recording it. To enforce size, sign the Content-Length (or use an S3 POST policy with a content-length-range condition, or the GCS x-goog-content-length-range header).
PUT, and treat an expiry 403 as "sign again", not as failed.The sequence also shows the most common failure. The signed URL expires after 15 minutes, so a file that sat in the queue behind a slow batch for 20 minutes gets 403 Forbidden from storage. Sign as late as possible, just before the PUT starts, and treat a 403 for an expired signature as "sign again and retry", not as a permanent failure.
Progress needs XMLHttpRequest
fetch is the modern API for requests, but it has no upload progress event. You can watch a download stream byte by byte, but not an upload. XMLHttpRequest has had xhr.upload.onprogress for over a decade: it fires as the browser hands bytes to the network, with loaded and total in bytes. So most uploaders with a smooth progress bar, including Uppy's XHR upload plugin and tus-js-client in the browser, still use XHR for the upload itself.
function putWithProgress(url: string, body: Blob, onProgress: (sent: number) => void, signal: AbortSignal) {
return new Promise<{ status: number; etag: string | null }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", body.type || "application/octet-stream");
xhr.upload.onprogress = (e) => onProgress(e.loaded);
xhr.onload = () => resolve({ status: xhr.status, etag: xhr.getResponseHeader("ETag") });
xhr.onerror = () => reject(new TypeError("network")); // offline, DNS, CORS
xhr.ontimeout = () => reject(new TypeError("timeout"));
xhr.onabort = () => reject(new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", () => xhr.abort(), { once: true });
xhr.send(body); // a File or a slice: streamed from disk
});
}
Three details. Passing a File or a Blob slice to send streams it from disk; the browser never loads it all into memory. onerror gives no reason at all: a CORS rejection, a dropped connection and a DNS failure look the same, so the caller treats it as transient and retries, and logs the URL host to tell CORS mistakes apart in monitoring. And upload.onprogress can reach 100 percent before the server has answered, because the bytes are in network buffers; show "Finishing" until onload, not "Done".
Cancel means two things
On the client, cancel aborts the request (xhr.abort() through the AbortSignal) and moves the item to cancelled. On the server, it must clean up: a single PUT that was aborted leaves nothing behind, but a chunked upload leaves parts in storage that are billed until someone aborts them. The uploader calls DELETE /api/uploads/:id after a cancel, and the backend should also have a safety net, such as an S3 lifecycle rule that aborts incomplete multipart uploads after a day.
Check your understanding · Exercise 6
Which statements about sending upload bytes from the browser are true? Select all that apply.
Large files: chunks, retries and resuming
A single PUT is fine up to roughly 100 MB, the size at which AWS suggests multipart uploads. Past that, a failure costs more and more: a 4 GB video on a 20 Mbps uplink takes about 27 minutes, and if the Wi-Fi drops at minute 24, a single-request upload restarts from zero. The fix is to split the file into chunks, send each as its own request, and remember which ones the server already has. A failure then costs one chunk, not the whole file.
Splitting is cheap: file.slice(start, end) returns a Blob that points at a byte range of the file on disk. Nothing is read until the request sends it, so memory stays flat even for a 50 GB file.
Decision Which resumable protocol should the uploader speak?
What we pick: The uploader doesn't pick one: the transport is a pluggable adapter, and the design system ships an S3 multipart adapter because our storage is S3. It signs part URLs in batches of 20 to keep our API out of the hot path, uploads 3 parts in parallel, and saves progress after every part. A team on GCS or tus writes another adapter with the same interface.
Choosing a chunk size
| Chunk size | Requests for a 4 GiB file | Work lost per failure | Time per chunk at 2 Mbps | Verdict |
|---|---|---|---|---|
| 1 MiB | 4,096 | 1 MiB | About 4 s | Too many requests, and below S3's 5 MiB minimum part size. |
| 8 MiB | 512 | 8 MiB | About 34 s | A good default: fits S3, small loss, a few seconds per chunk on normal networks. |
| 64 MiB | 64 | 64 MiB | About 4.5 min | Fine on fast fiber; on a phone, one chunk may never finish before the network blips. |
Smaller chunks lose less work and give the retry logic more chances; bigger chunks spend less time on request overhead (each request costs at least one round trip, often 50 to 300 ms on mobile). 8 MiB is a sensible default. Two refinements help: grow the chunk size for files over 80 GB so the 10,000-part limit still fits, and shrink toward the 5 MiB minimum when chunks keep timing out.
Retrying a failed chunk
Each chunk retries on its own, with exponential backoff and jitter: wait about 1 s, then 2, 4, 8, up to 30 s, each time a random amount between zero and that ceiling, so a thousand clients that lost Wi-Fi in the same office don't all retry in the same second. (The retries and backoff lesson covers the maths.)
Retry network errors, timeouts, 408, 429 (respect Retry-After), and 5xx. Don't retry 400, 413 or 415: the same request will fail the same way. While navigator.onLine is false, don't spend retry attempts at all; wait for the online event, then retry at once.
Resuming after a reload
Retries handle a dropped connection, but a reload, a crash, or a closed laptop lid wipes JavaScript memory. So after creating an upload and after every finished part, the uploader saves a record in IndexedDB (the browser's database for structured data; see the IndexedDB lesson): the upload id, the storage key, the chunk size, and the finished parts with their ETags. The key is a fingerprint of the file: name, size, lastModified and the destination folder.
The hard part is the file itself. A File from a picker is permission to read that file during this page's lifetime; after a reload, the page can't open it again on its own. There are three ways forward.
- Ask again. The person chooses the file again and the uploader matches it by fingerprint ("Resume holiday.mov? Choose it again to continue from 62 percent"). This works in every browser, and
tus-js-clientstores fingerprints for the same reason. - Keep a handle. On Chromium, a
FileSystemFileHandlefrom the File System Access API (fromshowOpenFilePickeror a drop) can be stored in IndexedDB and reopened after a permission prompt. - Keep a copy. Store the bytes in IndexedDB or the origin private file system. It works for small files but doubles disk use for a 4 GB video.
Before trusting the saved record, ask the server: ListParts for S3, a HEAD for tus, an empty PUT with Content-Range: bytes */TOTAL for GCS. The server's answer wins, because the last part may have arrived after the client crashed but before it saved. Also expire records: S3 multipart uploads are aborted by the lifecycle rule after a day, and GCS sessions last a week, so the client deletes older records and starts over.
Checksums: did the bytes arrive unchanged?
TCP and TLS catch random corruption on the wire, but bugs in slicing (an off-by-one byte range) or a proxy that mangles a body are caught only by an end-to-end checksum. crypto.subtle.digest("SHA-256", buffer) needs the whole buffer in memory and has no streaming mode, so hash each chunk, not the whole file: an 8 MiB chunk hashes in tens of milliseconds, in a worker.
S3 can check a per-part checksum you send with the part (SHA-256 or CRC32, among others) and rejects the part on a mismatch, which the client treats as a retryable error. If a whole-file hash is needed for deduplication, compute it from the chunk hashes, the way Dropbox's documented content hash combines the hashes of 4 MiB blocks.
Check your understanding · Exercise 7
A 3 GB upload uses 8 MiB S3 parts. The tab crashes at part 200 of 384, and the person reopens the page. Which steps are right? Select all that apply.
Keeping people informed: progress, time left, leaving, processing
An upload can take minutes. During those minutes, people want answers to three questions: is it moving, how long is left, and can I leave? A good uploader answers all three without being asked, and then a fourth one most designs forget: it says 100 percent, so why can't I use the file yet?
Time remaining that doesn't jump around
The obvious estimate divides the bytes left by the current speed. But the speed over the last moment swings wildly as TCP ramps up, Wi-Fi stalls for a second, or another tab starts a download, so the estimate says 2 minutes, then 40, then 3.
The fix is to smooth the speed with an exponential moving average: every second, speed = 0.2 * sampleSpeed + 0.8 * speed. One bad second barely moves the average, while a real slowdown shows up within 5 to 10 seconds.
function makeEta(alpha = 0.2) {
let speed = 0; // bytes per second, smoothed
let lastSent = 0;
let lastTime = performance.now();
return function sample(totalSent: number, totalBytes: number) {
const now = performance.now();
const seconds = (now - lastTime) / 1000;
if (seconds < 1) return null; // sample at most once a second
const instant = (totalSent - lastSent) / seconds;
speed = speed === 0 ? instant : alpha * instant + (1 - alpha) * speed;
lastSent = totalSent;
lastTime = now;
if (speed < 1) return null; // stalled: say "Waiting for network"
return (totalBytes - totalSent) / speed; // seconds left
};
}
Show the estimate in rough words, rounded up: "About 3 minutes left", "Less than a minute left", not "2 min 47 s" that people watch tick wrongly. Hide it for the first 3 to 5 seconds, while the average has too little data, and replace it with "Waiting for network" when nothing has moved for a few seconds. Base it on the batch, not on one file, because the batch is what the person is waiting for.
Leaving the page while uploads run
If someone closes the tab mid-upload, the uploads stop. Inside the app, keep the upload store at the app root so uploads continue across route changes, the way Google Drive's upload panel stays in the corner while you browse folders. Where that isn't possible, confirm before an in-app navigation while anything is uploading.
For closing or reloading the tab, add a beforeunload listener only while uploads are active, and call event.preventDefault() in it. The browser then shows its own generic "Leave site?" dialog: you can't change its text, and browsers show it only if the person has interacted with the page. Remove the listener when the queue is empty, because an always-on warning annoys people and has kept pages out of the back/forward cache in some browsers.
Decision Can uploads continue after the tab closes?
What we pick: Resume next time, plus the beforeunload warning. It's honest and works everywhere. Service workers and Background Fetch are worth a sentence in the interview as Chromium extras, but a design that depends on them fails every Safari and Firefox user. True background uploads belong to native apps: the Google Photos app backs up in the background, and a web page can't.
After 100 percent: processing
When the last byte arrives, the client calls POST /api/uploads/:id/complete. The server now does slow work: it checks the object's real type, scans it for malware, makes thumbnails and previews, maybe transcodes a video. This takes from about a second for a PDF to minutes for a long video, and if the UI says "Done" now, a click on the file gets an error. So the item moves to processing, with an indeterminate bar and a clear label, such as "Checking for viruses" or "Preparing preview".
The client learns the outcome in one of two ways. Polling GET /api/files/:id every 2 seconds, backing off to 10, is simplest and fine for a handful of files. If the app already keeps a WebSocket or server-sent events connection open (the Slack client keeps a WebSocket open for messages), the server pushes file.ready or file.rejected and the item updates the moment processing ends.
The component doesn't care which: the adapter exposes a waitForReady(fileId) promise. If the scan rejects the file, the item goes to failed with the reason ("This file was blocked because it may contain malware") and no retry button, because retrying can't change the verdict.
Check your understanding · Exercise 8
Which choices make an upload's progress and status more honest? Select all that apply.
Architecture and data model: the pieces and what they keep
We now know every job the uploader has. The architecture puts each job in one place, with a boundary that makes it testable and swappable. The guiding rule: the UI never talks to the network, and the network code never touches the DOM. Between them sits a plain JavaScript upload store that owns the items and their state machines.
| Piece | Job | Why it's separate |
|---|---|---|
| Input sources | Button with hidden input, drop zone hook, paste listener, folder walker | Each door has its own quirks; all end in one addFiles(files, source) call. |
| Validator | Runs the rule list per file, returns accepted items and rejections with messages | Pure functions with rules as data: easy to unit test, rules come from props. |
| Upload store | Holds the items, applies the transition function, exposes a subscribe method | One source of truth. React reads it with useSyncExternalStore, so updates don't depend on component lifetimes. |
| Scheduler | Starts queued items up to the concurrency limit, honours retryAt, priorities and pause-all | Policy in one place; the store stays a dumb state holder. |
| Transport adapter | Signs, sends bytes (single or chunked), reports progress, aborts, completes, waits for ready | Pluggable: S3 multipart, GCS, tus, or a test fake. The UI never changes. |
| Thumbnail worker | Decodes images and makes small preview blobs | Keeps decoding off the main thread. |
| Resume store | IndexedDB records of unfinished chunked uploads | Survives reloads; read once at start to offer resume. |
| UI components | Drop zone, file list, file row, batch summary, live region | Render from the store, send events to it. Products can replace any of them. |
Why a store outside React? Keeping items in a component's useState ties the uploads to that component: unmount the composer and the uploads lose their owner. An external store can live at the app root and outlive any page. It also lets us throttle: progress events arrive dozens of times a second per file, so the store applies each one to its data but notifies React at most about 10 times a second, which is plenty for a progress bar.
How data moves
onChange, and a fileId once the server says ready. Bytes travel on exactly one arrow, from the adapter to storage.Follow one file through the diagram: the validator turns File objects into items, the store saves them as queued, and the scheduler hands one to the adapter. The adapter gets a signed URL, sends bytes to storage while reporting progress, and calls complete. When processing reports back, the store moves the item to done with its fileId and the host's onChange receives it. The host never sees bytes, only ids, which is what lets one uploader serve email, chat and a files page.
Think first
A product team wants uploads to keep going when the person closes the composer and opens another email. Which piece moves, and what else changes?
The upload store and scheduler move up to the app root (a provider near the top of the tree), so they outlive the composer.
Items get an owner key, like a draft id, so the reopened composer shows only its own files and the draft attaches them when they finish.
A small global indicator ("Uploading 3 files") appears outside the composer, and beforeunload follows the global store, not the composer.
Data model: what the client keeps, and where
The uploader's data lives in several places with different lifetimes. The File exists only in memory for this page, progress changes 10 times a second, the resume record must outlive the page, and the finished file belongs to the server. Mixing these up is how uploaders end up trying to save a 4 GB File in localStorage (which fails) or losing all progress on reload.
type UploadStatus = "queued" | "uploading" | "paused" | "failed" | "processing" | "done" | "cancelled";
type UploadItem = {
id: string; // client id, crypto.randomUUID()
file: File | null; // null after a reload until the person re-selects it
name: string; // sanitized display name
relativePath?: string; // "trip/day1/IMG_0042.jpg" for folder uploads
size: number;
type: string; // sniffed type, falling back to file.type
status: UploadStatus;
sent: number; // bytes confirmed or in flight
attempts: number;
retryAt: number; // epoch ms; 0 when ready to start
error?: { code: string; message: string; retryable: boolean };
previewUrl?: string; // object URL of a thumbnail blob; revoke on remove
uploadId?: string; // storage session: S3 UploadId, tus URL, GCS session URI
fileId?: string; // our server's id once done
owner?: string; // which draft, message or folder this belongs to
};
type Rejection = { name: string; size: number; reason: string };
type ResumeRecord = {
fingerprint: string; // name + size + lastModified + destination
uploadId: string;
key: string; // storage object key
chunkSize: number; // must stay the same when resuming
parts: { number: number; etag: string; checksum?: string }[];
createdAt: number; // expire after the server's session lifetime
handle?: FileSystemFileHandle; // Chromium only: reopen without re-selecting
};
uploadId ties an item to its resume record; fileId ties it to the server's file, and that id is all the host app keeps.| Data | Lives in | Lifetime | Why there |
|---|---|---|---|
| Upload items and progress | External upload store in memory | The page | Changes many times a second; React subscribes to it. |
File objects | On the item, in memory | The page | A File can't be saved to localStorage; storing its bytes in IndexedDB costs disk. |
| Thumbnail blobs and object URLs | On the item | Until removed or unmounted | Must be revoked by hand, so they live next to the code that removes items. |
| Resume records | IndexedDB object store keyed by fingerprint | Until done or expired | Survives reloads and crashes; holds structured data and file handles. |
| Finished files | Server, referenced by fileId | Permanent | The host app stores only ids, not bytes. |
| Validation rules and limits | Props from the host | The component | Each product sets its own; the server has the final say. |
Two choices are worth defending. sent is a single number, because the UI needs one bar per file; the transport keeps per-part detail itself and in the resume record. And derived values (overall percent, counts per status, time left) are computed from the items in selectors and never stored, so they can't disagree with the items.
Check your understanding · Exercise 9
Which pairings of data and storage place are right? Select all that apply.
Interface: the component API and the server contract
A design system component succeeds or fails on its API. The email composer wants a compact row of chips, the files page a big drop area and a progress panel, the chat composer a single paperclip button with preview cards above the text box. If the API forces one look, two of those teams will fork it.
Decision How much of the uploader should the component own?
What we pick: A headless hook plus default parts. Uploader.Item also takes a renderPreview prop for the one thing teams customise most (a chat card shows a big image, an email chip shows an icon), so most teams never drop down to the hook. The list can be controlled: pass value and onChange and the host owns the list (useful when the list is part of a form draft that is saved and restored); omit them and the uploader keeps its own.
type UploaderProps = {
accept?: string[]; // [".pdf", "image/*"]
maxSize?: number; // bytes, per file
maxTotalSize?: number; // bytes, whole list (email attachments)
maxFiles?: number;
minImageSize?: { width: number; height: number };
validate?: (file: File) => string | null | Promise<string | null>; // extra product rules
multiple?: boolean;
directory?: boolean; // show "Upload folder"
dropTarget?: "self" | "page" | "none";
acceptPaste?: boolean;
concurrency?: number; // default 3
adapter: UploadAdapter; // how bytes travel
value?: UploadItem[]; // controlled mode
onChange?: (items: UploadItem[]) => void;
onFileReady?: (item: UploadItem & { fileId: string }) => void;
onReject?: (rejections: Rejection[]) => void;
label?: string; // "Add attachments"
};
interface UploadAdapter {
/** Starts or resumes; calls onProgress with total bytes sent; resolves with our file id. */
upload(item: UploadItem, ctx: {
signal: AbortSignal;
onProgress: (sent: number) => void;
saveResume: (record: ResumeRecord) => Promise<void>;
}): Promise<{ fileId: string }>;
/** Waits for scanning and thumbnails; rejects with a reason if the server blocks the file. */
waitForReady(fileId: string, signal: AbortSignal): Promise<void>;
/** Discards partial data after a cancel. */
abort(item: UploadItem): Promise<void>;
/** Tells the queue which errors to retry. */
isRetryable(error: unknown): boolean;
}
Pausing aborts the request through the signal and keeps the resume data; resuming calls upload again, and the adapter continues from where the server says it is. So the adapter needs no separate pause method to get wrong.
The server contract for the S3 adapter
POST /api/uploads
{ "name": "holiday.mov", "size": 4294967296, "type": "video/quicktime",
"fingerprint": "holiday.mov:4294967296:1760941200000", "folderId": "f_91" }
→ 201 { "uploadId": "u_7Qp", "key": "users/42/u_7Qp", "chunkSize": 8388608,
"partCount": 512, "expiresAt": "2026-10-21T14:00:00Z" }
→ 413 { "error": { "code": "file_too_large", "message": "Files can be up to 5 GB.",
"retryable": false, "limit": 5000000000 } }
POST /api/uploads/u_7Qp/parts { "partNumbers": [1, 2, 3, ..., 20] }
→ 200 { "urls": { "1": "https://bucket.s3.amazonaws.com/...&X-Amz-Signature=...", "2": "..." },
"expiresIn": 900 }
GET /api/uploads/u_7Qp/parts → 200 { "parts": [{ "number": 1, "etag": "\"a54f...\"" }] }
POST /api/uploads/u_7Qp/complete { "parts": [{ "number": 1, "etag": "\"a54f...\"" }] }
→ 202 { "fileId": "file_3k9", "status": "processing" }
GET /api/files/file_3k9 → 200 { "status": "ready", "thumbnailUrl": "..." }
DELETE /api/uploads/u_7Qp → 204
Note the error shape: a stable code for the client's logic, a human message, and a retryable flag that isRetryable reads directly, so the server decides which failures are worth another try. The server re-validates size and type at create time, so a bad file fails before any bytes move. The 202 Accepted on complete means "received, still working", which maps exactly to the processing status.
Check your understanding · Exercise 10
A team wants uploads in their app to go to their own tus server instead of S3. With this API, what do they need to do? Select all that apply.
Security: every file is hostile until proven otherwise
An uploader lets strangers put files on your servers and in front of your other users. The client can make honest mistakes rare, but every protection that matters runs on the server or in how files are served back. Say that clearly in the interview, then show you know the specific attacks; the input validation and XSS lessons cover the general principles.
| Attack | What happens | Defense |
|---|---|---|
| Lying about the type | evil.html uploaded as image/png, then opened in the browser | Server sniffs the stored bytes, ignores the client's Content-Type, and checks an allowlist. Serve with X-Content-Type-Options: nosniff. |
| HTML or SVG with scripts | An SVG is XML and may contain script tags. Opened directly from our domain, its script runs with our origin's access. | Serve user files from a separate domain (Google uses googleusercontent.com), with Content-Disposition: attachment for anything risky. Sanitize or rasterize SVGs before showing them inline. |
| Script in the filename | A file named <img src=x onerror=alert(1)>.png is legal on macOS and Linux | Render names as text (React does this by default); never put them in innerHTML or an unescaped template. |
| Path tricks | A name like ../../config.json or a 2,000-character name | Store objects under random keys; keep the original name only as metadata, trimmed to 255 bytes, with path separators and control characters removed. |
| Spoofed extensions | invoice + the right-to-left override character U+202E + fdp.exe displays as invoiceexe.pdf | Strip bidirectional control characters from names before showing or storing them. |
| Decompression bombs | A 50,000 by 50,000 pixel PNG of a few hundred KB that needs 10 GB to decode, or a tiny zip that expands to terabytes | Read dimensions from the header and refuse huge ones before decoding; cap unzip sizes. |
| Malware | A real virus in a real PDF, shared with coworkers | Keep new files in a quarantine state until a scanner passes them; that's the processing status. |
| Stolen or reused upload URLs | A signed URL leaks into logs or analytics | Sign one method, one key, one size, for about 15 minutes. Never log full signed URLs. Download URLs are signed separately and short-lived too. |
Content-Disposition decides whether the browser shows a file or downloads it. Content-Disposition: attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf forces a download with the right name, even for non-Latin characters. Serving an uploaded HTML file inline from your own domain is the classic stored XSS; serving it as an attachment from a separate cookieless domain removes the danger. That's why products that let users upload anything put downloads on a different domain from the app.
SVG surprises people because it's an image format. Inside an img tag, browsers run no scripts in it, so an avatar shown with img src is safe. The danger comes when someone opens the SVG URL directly, or the app inlines the SVG markup to style it. So either restrict avatars to raster formats (PNG, JPEG, WebP), or sanitize SVGs on the server with an allowlist of elements and serve them from the user content domain.
Check your understanding · Exercise 11
Users can upload profile pictures, and the app shows them in an img tag. Which risks still need handling? Select all that apply.
Accessibility: uploads for keyboards and screen readers
Drag and drop is the most visible part of an uploader and the least accessible: someone using a keyboard, a switch device, or a screen reader can't drag a file from the desktop. That's why the design started with a labelled button and treats the drop zone as a bonus for mouse users. The screen readers lesson covers live regions and names in depth; here's how they apply.
- The button: a real
buttonwith a visible label such as "Add files", and the limits as a description:aria-describedbypointing at "PDF, PNG or JPG, up to 25 MB". The hiddeninputstays out of the tab order and the accessibility tree (display: noneor thehiddenattribute does both, andinput.click()still opens the picker), so focus meets one control, not two. - The drop zone text says "or drop files here", so it never suggests that dropping is the only way.
- The list is a
ullabelled "Attachments". Each row reads as a sentence: name, size, and status in words ("holiday.mov, 1.2 GB, uploading, 45 percent"), not just a coloured bar. - Progress: a native
progresselement orrole="progressbar"witharia-valuenow, plusaria-valuetextsuch as "45 percent, about 2 minutes left". Inprocessing, droparia-valuenowso it reads as busy, not stuck at 100. - Row actions: real buttons whose names include the file, like "Cancel holiday.mov" and "Retry report.pdf". Five buttons all called "Cancel" are useless in a screen reader's list of buttons.
- Focus after removal: move focus to the next row's first button, or the previous row's, or back to the add button when the list is empty. Never let focus fall to the page body.
- Colour and motion: failed rows show an icon and the word "Failed", not only red, and animated progress stripes stop under
prefers-reduced-motion.
Announcing progress without noise
A screen reader doesn't watch a progress bar. You tell it what changed through a live region: an element with role="status" (polite: it waits until the reader is quiet) whose text you update. The trap is updating it on every progress event, which buries the person under "41 percent, 42 percent, 43 percent".
Announce the events that matter: files added and rejected ("3 files added. 1 not added: setup.exe isn't a supported type."), each failure as it happens, each finished file in a small batch or a summary in a large one ("12 of 40 uploaded", at most every 15 seconds), and the end ("All 40 files uploaded"). Progress for one file is available on demand by moving to its row.
function makeAnnouncer(region: HTMLElement, every = 15_000) {
let last = 0;
const say = (text: string) => {
region.textContent = ""; // clear so a repeat is read again
requestAnimationFrame(() => (region.textContent = text));
};
return {
now: say, // added, rejected, failed, all done
progress(done: number, total: number) {
if (Date.now() - last < every) return;
last = Date.now();
say(`${done} of ${total} files uploaded`);
},
};
}
Check your understanding · Exercise 12
A screen reader user adds 20 files with the keyboard. Which behaviours help them? Select all that apply.
Running it in the interview
A file uploader is a component question with a surprising amount of system design inside it. Interviewers like it because a weak answer stops at "an input, a drop zone and a progress bar", while a strong one finds the state machine, the queue, the transport and resume. The plan below fits 45 minutes.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: file sizes, counts, input methods, destination, processing, resume | Write the three numbers that drive the design: max file size, max files per drop, slowest network. |
| 5 to 10 | Name the hard parts and sketch the pipeline | Doors, validate, queue, transport, process. Say: "Each file is a state machine; the queue schedules them." |
| 10 to 18 | State machine and queue | Draw the statuses and arrows. Concurrency of 3, transient versus permanent errors, byte-weighted progress. |
| 18 to 28 | Transport and large files | Pre-signed URLs, XHR for progress, chunks of 8 MiB, retries with backoff, resume through IndexedDB and the server's part list. |
| 28 to 35 | Component API and architecture | Headless hook plus parts, adapter interface, store outside React. Show the props. |
| 35 to 42 | Security and accessibility | Server re-validation, separate download domain, SVG; button first, named row buttons, throttled live region. |
| 42 to 45 | Wrap up | Summarise the trade-offs you made and what you'd measure in production. |
Draw the state machine first. It's the single picture that proves you understand the problem, and every later topic hangs off it: retries are a loop from uploading back to queued, cancel is an arrow from each status before the bytes are sent, processing is the status after 100 percent. Then draw the three-step transport (sign, put, complete) as a small sequence, because it explains why the app server isn't in the byte path.
Follow-up questions to expect
Think first
"Two people upload the same 2 GB video. Can we skip the second upload?"
Yes, with content hashing: the client computes per-chunk SHA-256 hashes in a worker and a whole-file hash from them, and sends it at create time. If the server already has that hash, it links the existing object and the upload finishes instantly.
The catch is privacy and cost: hashing 2 GB takes seconds of CPU, and instant dedupe across users reveals that someone else has the file. Many products dedupe only within one account.
Think first
"Uploads keep failing on mobile when the phone locks. What do you do?"
Mobile browsers suspend background tabs, so a locked phone pauses the page. Chunked uploads make that cheap: the current chunk fails, and on visibilitychange back to visible, the queue retries and continues from the server's last part.
Use smaller chunks on slow or metered connections, and say honestly in the UI: "Keep this page open until uploads finish". For true background uploads, the answer is a native app.
Think first
"How do you know the uploader works well in production?"
Track per upload: size, chunk count, retries, time to first byte sent, throughput, final status and error codes, by browser and network type. Success rate by size bucket shows whether large files struggle.
Watch for abandonment (uploads cancelled or the page closed mid-upload), resume rate after reloads, and processing time. Alert on spikes of storage 403 errors, which usually mean signing or CORS broke in a deploy.
Check your understanding · Exercise 13
You have five minutes left and haven't covered resume. Which summary shows the most senior judgment? Select all that apply.
Summary
- Start from a labelled button and a real file input; drop zones, paste and folders are enhancements that all feed one
addFilesfunction. - Validate on the client for speed and kindness (count, size, extension, magic bytes, dimensions, per file with clear messages), and again on the server for safety.
- Make previews cheap: thumbnails from
createImageBitmapin a worker, and revoke every object URL you create. - Model each file as a state machine with one status field: queued, uploading, paused, failed, processing, done, cancelled. Transient errors loop back to queued with backoff; permanent ones fail.
- Schedule with a concurrency limit (3 by default) and weight overall progress by bytes, not by file count.
- Send bytes straight to storage with short-lived pre-signed URLs, and use
XMLHttpRequestfor upload progress, becausefetchhas none. - Split large files into chunks of about 8 MiB, retry chunks on their own, save progress in IndexedDB, and trust the server's list of received parts when resuming.
- Show processing after 100 percent, warn with
beforeunloadonly while uploads run, and announce milestones, not every percent. - Keep the transport behind an adapter and the logic in a headless hook, so one uploader serves attachments, a files page and a chat composer.
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: Using files from web applications File inputs, drag and drop, object URLs and uploads with progress.
- MDN: HTML Drag and Drop API The drag events,
DataTransfer, and dropping files. - MDN: XMLHttpRequest upload property Upload progress events, the reason XHR is still used for uploads.
- MDN: DataTransferItem.webkitGetAsEntry() Reading dropped folders, with browser support.
- Chrome for Developers: Streaming requests with the fetch API What request streaming can and can't do today.
- Amazon S3: Uploading and copying objects using multipart upload Parts, limits, completing and aborting multipart uploads.
- Amazon S3: Multipart upload limits Part sizes, the 10,000-part limit, and the 100 MB rule of thumb.
- MDN: Window beforeunload event When the leave-page dialog appears and why to add the listener only when needed.
- Google Cloud Storage: Resumable uploads Session URIs, chunk sizes and checking upload status.
- tus: resumable upload protocol The open protocol: creation, offsets, and extensions.
- OWASP: File Upload Cheat Sheet Server-side checks, storage and serving of uploaded files.
Concepts behind it
- Uploading filesHow a file gets from the user's device to your storage: through your server or straight to object storage with a pre-signed URL, in resumable chunks with honest progress, checked on both sides, and served back without opening a security hole.
- 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.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- 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.