cf.completefrontend
My progress
SYSTEM DESIGN · #15

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.

QuestionAnswer we assumeWhat 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

QualityTargetWhy this number
Feedback after a dropRows appear within 100 ms, even for 1,000 filesPeople need to see the drop worked. 100 ms feels instant.
Main threadNo task over 50 ms while making previewsLong tasks make the page stutter and hurt Interaction to Next Paint.
ThroughputUse most of the uplink: a 20 Mbps uplink should move a 1 GB file in about 7 minutes1 GB is 8,000 megabits; 8,000 divided by 20 is 400 seconds.
ResilienceA 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.
MemoryNever read a whole large file into memoryA 4 GB ArrayBuffer crashes a phone tab. Read slices on demand.
AccessibilityEvery action works without a mouse; progress is announced, but not every 1 percentDrag and drop is a mouse-only gesture; screen reader users need a real button.
SecurityThe server re-checks everything; uploaded files never run as code on our originClient 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.

  1. 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.
  2. Files are untrusted and often wrong. A 300 MB video in an avatar field, report.exe renamed to report.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.
  3. 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.
  4. 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.
  5. 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.

The uploader's pipeline. Four doors, the picker, a drop, a paste and a folder, all feed Validate, which checks type, size and count. Files that fail a rule go to Rejected. Accepted files join the Queue, which keeps at most 3 uploading. The Queue hands files to Transport, which signs, sends and chunks, and on a transient error puts the file back in the queue to retry. Transport sends bytes to Storage. Storage hands the file to Processing, which scans and makes thumbnails, and then the file is Done with a fileId for the host app.pickerdroppastefolderValidatetype, size, countRejectedQueuemax 3 uploadingTransportsign, PUT, chunkstransient error: retryStoragebytesProcessingscan, thumbnailsDonefileId to host
Every door ends in the same validator, so nothing later cares where a file came from. The only loop is a transient error going back to the queue; everything else moves one way, toward done or rejected.

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.

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?

Drop zone only

A large dashed box that says "Drop files here". Clicking it may open the picker.

Pros

  • Looks clean.
  • One element to style.

Cons

  • Dragging is a mouse gesture: keyboard and screen reader users, and many touch users, can't do it.
  • A clickable div needs a role, a name, and key handling rebuilt by hand.

Button first, drop zone as an enhancement

A labelled button opens the picker. The same area (or the whole page) also accepts drops and highlights while a drag is over it.

Pros

  • Works for everyone, everywhere, from the start.
  • Drop support can be added or removed per product without changing the core.

Cons

  • Two code paths feed the same list, so both must call one addFiles function.

Whole page drop target

Listen on document: any drop anywhere adds files, the way Slack and Google Drive show a full-screen overlay.

Pros

  • Big target, hard to miss, for apps whose main job is files.

Cons

  • Clashes with other drop targets, like a rich text editor that accepts image drops.
  • Still needs the button for non-mouse users.

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.

Drag events as the pointer enters the drop zone, moves onto its text, back onto the zone, and out. The events are: dragenter zone, dragenter text, dragleave zone, dragenter zone, dragleave text, dragleave zone. A naive flag that turns on at every enter and off at every leave reads on, on, off, on, off, off: it switches off twice while the pointer is still inside the zone. A depth counter reads 1, 2, 1, 2, 1, 0, so the zone stays highlighted until the pointer really leaves.events: + is dragenter, - is dragleavenaive: on at every enter, off at every leavedepth counter: highlighted while above 0+zoneon1+texton2-zoneoff1+zoneon2-textoff1-zoneoff0red: highlight off while the pointer is still over the zone
The browser fires 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.

ts
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);
    },
  };
}
A drop zone hook with a depth counter. Only file drags light it up.

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.

ts
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;
}
Walking a dropped folder. Grab the entries before any await.

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.

CheckHowCostExample message
CountExisting items plus new ones against maxFilesFree"You can attach up to 10 files. 3 were not added."
Sizefile.size against maxSize; also a total for the batchFree"holiday.mov is 1.2 GB. The limit is 25 MB."
Empty filefile.size === 0Free"notes.txt is empty."
Type by extension and MIMEfile.name ending and file.type against acceptFree"setup.exe isn't a supported type. Use PDF, PNG or JPG."
Type by contentRead the first bytes and compare with known signaturesOne small read, about 1 ms"photo.jpg doesn't look like a JPG file."
Image dimensionsDecode the image headerA few ms to 100+ ms for big photos"logo.png is 120 by 80 pixels. Use at least 200 by 200."
DuplicatesSame name, size and lastModified already in the listFree"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.

ts
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
}
Sniffing a file's real type from its first bytes. Never read the whole file for this.

Decision How strict should the client's type check be?

Trust file.type

Compare the browser's MIME guess with the allowed list.

Pros

  • One line, no reads.

Cons

  • It's only the extension in disguise.
  • Empty for many valid files, so it rejects good uploads on some systems.

Extension and MIME, with magic bytes for known formats

Accept if the extension is allowed. For formats with a known signature (images, PDF), also check the first bytes and warn or reject on a mismatch.

Pros

  • Catches honest mistakes like a renamed file.
  • Cheap: 16 bytes per file.
  • Unknown formats still pass on extension.

Cons

  • A small signature table to maintain.
  • Still not security: an attacker can forge the first bytes.

Full parse on the client

Decode every image, open every PDF, unzip every archive before upload.

Pros

  • Finds broken files early.

Cons

  • Slow and heavy on the main thread for big files.
  • Duplicates work the server must do anyway.

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?

Object URL of the original in an img

img.src = URL.createObjectURL(file), shown at 64 by 64 pixels with CSS.

Pros

  • One line.
  • decoding="async" lets the browser decode without holding up the rest of the page.

Cons

  • The browser decodes the full image: a 12 megapixel photo needs about 48 MB of pixel memory to show a 64 pixel square.
  • 200 of them is several GB of decoded images, which crashes phone tabs.

Downscale on a canvas on the main thread

Load the image, drawImage it onto a 128 pixel canvas, export a small JPEG with canvas.toBlob.

Pros

  • Small thumbnails, small memory after the work is done.

Cons

  • Decoding and scaling a big photo can take 50 to 200 ms, which is a long task per file.
  • A drop of 100 photos freezes the page for seconds.

createImageBitmap in a worker

Send the File to a Web Worker. It calls createImageBitmap with resize options, draws on an OffscreenCanvas, and returns a small JPEG Blob.

Pros

  • Decoding and scaling happen off the main thread, so the page stays responsive.
  • The result is a 5 to 15 KB blob per thumbnail.
  • The same decode can report width and height for validation.

Cons

  • More code: a worker, messages, a small pool.
  • Current Chrome, Firefox and Safari support the resize options, but older versions ignore them, so the worker checks the bitmap size and scales on the canvas if needed.

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.

ts
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
  }
};
thumb.worker.ts: a 128 pixel JPEG thumbnail, made off the main thread.

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.

Two ways to preview a 4 MB, 12 megapixel photo. Left: the original in an img element shown at 64 pixels still decodes at full size, about 48 MB of pixels; 200 photos need about 9.6 GB. Right: a worker calls createImageBitmap, resizes to 128 pixels and closes the big bitmap, leaving a thumbnail blob of about 10 KB and about 64 KB of pixels; 200 photos need about 15 MB.original in an imgIMG_0042.jpg4 MB file, 12 MPimg, 64 px on screendecodes at full sizedecoded pixelsabout 48 MB200 photos: about 9.6 GBIMG_0042.jpg4 MB file, 12 MPworkercreateImageBitmap, close()thumbnail blob10 KB file, 64 KB pixelsthumbnail from a worker200 photos: about 15 MB
The screen size of a preview doesn't decide its memory; the decoded size does. A worker thumbnail pays for the full decode once, off the main thread, then frees it with 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.

The upload state machine. Queued goes to Uploading when a slot is free. Uploading goes back to Queued on a transient error, to wait and retry. Uploading goes to Paused on pause, and Paused goes back to Queued on resume. Uploading goes to Failed when it gives up. Failed goes back to Queued on retry. Uploading goes to Processing when all bytes are sent. Processing goes to Done when the server says ready, or to Failed when the scan rejects the file. Queued, Uploading and Paused can go to Cancelled; the arrow is drawn from Paused.queueduploadingpausedfailedprocessingdonecancelledslot freetransient errorpauseresumegave upretrybytes sentscan rejectsreadycancelcancel also fromqueued, uploading
One 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.
StatusMeansLeaves onGoes to
queuedValid, waiting for a free slotslot free / canceluploading / cancelled
uploadingBytes are movingall bytes sent / error / pause / cancelprocessing / failed or queued (auto retry) / paused / cancelled
pausedStopped by the person; a chunked upload keeps its finished partsresume / cancelqueued / cancelled
failedGave up after retries, or a permanent errorretry / removequeued / removed
processingServer has the bytes, scanning and making thumbnailsserver says ready / server rejectsdone / failed
doneReady; has a server idremoveremoved (and deleted on the server if the host asks)
cancelledStopped for good; server told to discard partial dataremoveremoved

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.

ts
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
}
The transition function. Events that don't fit the current status are dropped.

How many at once

Decision How many files should upload at the same time?

All at once

Start a request for every queued file immediately.

Pros

  • Simple.
  • Fine for 3 files.

Cons

  • On HTTP/1.1 the browser allows about 6 connections per host and silently queues the rest, so progress bars sit at 0.
  • Hundreds of streams share the uplink, so every file is slow and none finishes early.
  • Other requests from the app (search, autosave) wait behind the uploads.

One at a time

A strict line: the next file starts when the previous one ends.

Pros

  • Predictable order, easy to reason about.

Cons

  • Wastes the uplink on many small files: each one pays a full round trip and a request setup before sending a byte.
  • One stuck file blocks everything behind it.

A fixed limit, like 3

At most N items in uploading. When one leaves that status, the scheduler starts the next queued item.

Pros

  • Fills the pipe for small files while leaving room for the rest of the app.
  • Files finish one by one, so people see real progress.
  • One number to tune per product.

Cons

  • The best N depends on file sizes and the network; a fixed value is a compromise.

Adaptive limit

Start at 2; raise the limit while total throughput rises, lower it on errors or timeouts.

Pros

  • Uses fast networks fully and backs off on slow ones.

Cons

  • More code and harder to test.
  • Small gains for most products.

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.

Concurrency
photo1.jpg: queued, 0.0 of 3 MB
video.mp4: queued, 0.0 of 24 MB
notes.pdf: queued, 0.0 of 2 MB
archive.zip: queued, 0.0 of 6 MB
slides.key: queued, 0.0 of 12 MB
photo2.jpg: queued, 0.0 of 4 MB
song.mp3: queued, 0.0 of 8 MB

0 of 7 files done.

Upload queue at 0 seconds, concurrency 3. photo1.jpg queued, 0 of 3 MB. video.mp4 queued, 0 of 24 MB. notes.pdf queued, 0 of 2 MB. archive.zip queued, 0 of 6 MB. slides.key queued, 0 of 12 MB. photo2.jpg queued, 0 of 4 MB. song.mp3 queued, 0 of 8 MB. Overall 0 percent by bytes.photo1.jpgqueued0/3 MBvideo.mp4queued0/24 MBnotes.pdfqueued0/2 MBarchive.zipqueued0/6 MBslides.keyqueued0/12 MBphoto2.jpgqueued0/4 MBsong.mp3queued0/8 MBtime: 0 suploading: 0 of 3overall: 0% by bytesdone: 0 of 7
7 files added. Press +1 s to let time pass.

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?

Multipart form POST to the app server

POST /api/files with a multipart/form-data body. The app server streams the file to S3 or disk, then saves a record.

Pros

  • One request, one auth check, simplest code on both sides.
  • The server sees every byte, so it can hash or scan while streaming.

Cons

  • Every byte passes through our servers twice (in, then out to storage): bandwidth cost and workers busy for minutes per upload.
  • Proxy and load balancer limits on request size and time (often 1 to 100 MB and 60 seconds) cap file size.
  • No resume: a failure restarts the whole file.

Pre-signed URL, direct to storage

The client asks our API for permission: POST /api/uploads with name, size and type. The API checks the rules and returns a pre-signed URL: an S3 or GCS address with a signature that allows exactly one PUT to one key for a few minutes. The client sends the bytes there, then tells our API it's finished.

Pros

  • Our servers handle two small JSON requests; storage handles the heavy traffic.
  • No proxy size limits in the way: S3 takes up to 5 GB in one PUT.
  • The same pattern extends to multipart, chunked uploads.

Cons

  • Three steps instead of one, so more failure points: sign, upload, complete.
  • The bucket needs a CORS rule for our origin, and must expose the ETag header for chunked uploads.
  • We must enforce size and type in the signature, or a client can upload something else to the signed key.

A resumable upload server (tus)

Run or buy a server that speaks the open tus protocol, which handles chunks and resume itself, then moves the finished file to storage.

Pros

  • Resume built in, with good client libraries like Uppy and tus-js-client.

Cons

  • Another service to run, and the bytes go through it.

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).

Sequence for uploading one photo. The user drops photo.jpg on the Uploader, which validates it, marks it queued and shows a preview. The Uploader sends POST /api/uploads to our API, which replies with a signed URL valid for 15 minutes. The Uploader sends a PUT with the bytes straight to Storage, with progress events moving the bar, and Storage replies 200 with an ETag. The Uploader posts complete to our API, which replies 202 and the row shows processing. Failure path: if the file waited too long, Storage replies 403 because the URL expired, and the Uploader signs again and retries the PUT.UserUploaderOur APIStoragedrop photo.jpgqueued, previewPOST /api/uploadssigned URL, 15 minPUT, progress200, ETagPOST complete202 processing403: URL expiredsign again, retry
Our API handles two small JSON calls; the bytes go straight to storage. The red reply is the common failure: a signature that expired while the file waited in the queue. Sign just before the 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.

ts
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
  });
}
One PUT with progress and cancel. The AbortSignal makes it look like fetch to the rest of the code.

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?

S3 multipart upload

Our API calls CreateMultipartUpload and gets an UploadId. For each part, the client gets a pre-signed URL and PUTs the bytes, keeping the ETag from each response. At the end, our API calls CompleteMultipartUpload with every part number and ETag. ListParts tells you which parts exist.

Pros

  • Parts upload in parallel and in any order.
  • Native to S3 and S3-compatible stores (Cloudflare R2, MinIO).
  • Parts are 5 MiB to 5 GiB (the last may be smaller), up to 10,000 parts.

Cons

  • Our API is involved per part (signing) unless it signs a batch of part URLs at once.
  • Incomplete uploads are billed until aborted.
  • The bucket's CORS must expose ETag, or the browser can't read it.

tus

An open HTTP protocol for resumable uploads. A POST creates an upload with Upload-Length (the creation extension); each PATCH sends bytes with an Upload-Offset header; a HEAD asks the server for its current offset.

Pros

  • Resume is the protocol's core idea: one HEAD answers "where were we?"
  • Mature clients (tus-js-client, Uppy) and servers; Vimeo and Cloudflare Stream accept tus uploads.

Cons

  • Needs a tus server in front of storage.
  • The core protocol is sequential; parallel parts need the concatenation extension.

Google Cloud Storage resumable upload

A first POST returns a session URI. The client PUTs chunks with a Content-Range header; the server answers 308 Resume Incomplete with a Range header saying how much it has, until the final chunk returns 200 or 201.

Pros

  • Built into GCS and Google APIs (Drive and YouTube use the same idea).
  • The session URI is itself the credential, valid for up to a week.

Cons

  • Chunks are sequential, one at a time.
  • Chunk sizes must be multiples of 256 KiB, except the last one.

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 sizeRequests for a 4 GiB fileWork lost per failureTime per chunk at 2 MbpsVerdict
1 MiB4,0961 MiBAbout 4 sToo many requests, and below S3's 5 MiB minimum part size.
8 MiB5128 MiBAbout 34 sA good default: fits S3, small loss, a few seconds per chunk on normal networks.
64 MiB6464 MiBAbout 4.5 minFine 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.

Chunked upload of 12 chunks at 0 seconds, phase uploading. The server has chunks none. The IndexedDB record lists 0. In flight: 1, 2, 3.on the serverIndexedDB recordthis second: up is sending, red waits to retry1up2up3up4567891011120 of 12 chunks stored, 0 of 96 MiB
A short demo: holiday.mov in 12 chunks of 8 MiB, 3 at a time. Press Next second.

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-client stores fingerprints for the same reason.
  • Keep a handle. On Chromium, a FileSystemFileHandle from the File System Access API (from showOpenFilePicker or 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.
Sequence for resuming after a reload. The user reopens the page. The Uploader reads records from IndexedDB, which replies upload u_7Qp with 200 of 384 parts. The Uploader asks the user to choose the file again, and the user picks the same file, whose fingerprint matches. The Uploader asks our API for the parts, and the API replies that 201 parts are on the server. The Uploader sends parts 202 to 204 to Storage. The network drops and part 203 fails. The Uploader retries part 203 after 2 seconds.UserUploaderIndexedDBOur APIStoragereopens pageread records200 of 384choose againsame fileGET partsserver has 201PUT 202 to 204203 fails2 s, retry 203
The local record said 200 parts; the server says 201, because the last part landed after the crash. The server's list wins. A failed part costs one 8 MiB retry, not the whole file.

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.

ts
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
  };
}
A smoothed estimate for the whole batch, sampled once a second.

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.

Time left over 12 samples, one per second, for the same upload. The raw estimate, from the speed in the last moment, jumps between 3 and 14 minutes: 3, 12, 4, 14, 5, 4, 11, 5, 4, 6, 4, 3. The smoothed estimate, from an exponential moving average of the speed, stays between about 4 and 6 minutes and falls slowly: 5, 5.4, 5.2, 5.9, 5.6, 5.3, 5.6, 5.2, 4.8, 4.6, 4.2, 3.8.minutes left140seconds, one sample eachraw: 3 to 14 minutessmoothed: about 5, falling
Illustrative numbers from one bumpy upload. Each raw spike is a one-second stall; the moving average lets each sample move it by only a fifth, so a stall barely shows while a real slowdown still comes through within seconds.

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?

No: resume next time

Uploads run in the page. On return, the IndexedDB records offer "Resume 2 unfinished uploads".

Pros

  • Works in every browser.
  • Simple to reason about: the page owns the work.

Cons

  • Nothing moves while the tab is closed.

Run uploads in a service worker

The page hands files to a service worker, which sends them with fetch.

Pros

  • Survives navigations within the site.

Cons

  • Browsers stop a service worker that sits idle (about 30 seconds in Chrome) and cap how long one event can keep it alive, so long uploads are fragile, and nothing keeps it going once the last tab closes.
  • No upload progress in fetch, so the page shows less.

Background Fetch API

registration.backgroundFetch.fetch(id, requests) hands requests to the browser, which runs them with a system progress notification even after the tab closes.

Pros

  • Real background transfers with system UI.

Cons

  • Only Chromium browsers support it.
  • Designed mainly for large downloads. It can send request bodies and report bytes uploaded, but few products use it for uploads.

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.

The uploader's architecture. The host app, a composer or files page, passes props to the Uploader component and gets onChange back. Inside the component: UI parts (DropZone, List, Item) and Input sources (picker, drop, paste). Input sources feed the Validator, which applies rules from props and gives items to the Upload store, the single source of truth that the UI parts render from. The Validator sends images to the Thumbnail worker. The store feeds the Scheduler, which allows at most 3 uploads and honours retry times. The Scheduler starts the Transport adapter (S3, GCS or tus), which saves progress to the Resume store in IndexedDB, asks our API to sign and complete, and sends bytes to Storage.Host app: composer or files pageUploader componentprops, onChangeUI partsDropZone, List, ItemInput sourcespicker, drop, pasteUpload storeitems, transitionsValidatorrules from propsSchedulermax 3, retryAtThumbnailsworker, small blobsResume storeIndexedDBAdapterS3, GCS or tusUI never callsthe networkOur APIsign, completeStorageS3 bucket
The UI never calls the network and the adapter never touches the DOM. The store in the middle is plain JavaScript, so it can live at the app root and keep uploads going while people move between pages.
PieceJobWhy it's separate
Input sourcesButton with hidden input, drop zone hook, paste listener, folder walkerEach door has its own quirks; all end in one addFiles(files, source) call.
ValidatorRuns the rule list per file, returns accepted items and rejections with messagesPure functions with rules as data: easy to unit test, rules come from props.
Upload storeHolds the items, applies the transition function, exposes a subscribe methodOne source of truth. React reads it with useSyncExternalStore, so updates don't depend on component lifetimes.
SchedulerStarts queued items up to the concurrency limit, honours retryAt, priorities and pause-allPolicy in one place; the store stays a dumb state holder.
Transport adapterSigns, sends bytes (single or chunked), reports progress, aborts, completes, waits for readyPluggable: S3 multipart, GCS, tus, or a test fake. The UI never changes.
Thumbnail workerDecodes images and makes small preview blobsKeeps decoding off the main thread.
Resume storeIndexedDB records of unfinished chunked uploadsSurvives reloads; read once at start to offer resume.
UI componentsDrop zone, file list, file row, batch summary, live regionRender 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

Data flow for one file. A drop, paste or pick gives File objects to the Validator, which gives items to the Upload store. The store tells the Host app through onChange. The store starts the Adapter and receives progress from it. The Adapter asks our API to sign and later to complete, and sends bytes to Storage. Our API starts processing, which scans the file and makes thumbnails, and then sends a ready event back to the store, which moves the item to done with its fileId.drop, pasteFile[]ValidatoritemsUpload storeHost apponChangeAdapterstart, progressOur APIsign, completereadyProcessingscanStoragebytesthe host gets ids, never bytes
Only two kinds of things reach the host: the item list through 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?

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.

ts
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 };
The upload item: one per file, owned by the upload store.
ts
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
};
The resume record in IndexedDB: everything needed to continue, nothing that can't be stored.
The data model in three places. UploadItem lives in the upload store in memory, for this page only: status and sent bytes, the File, a preview URL to revoke, and the uploadId and fileId. ResumeRecord lives in IndexedDB until the upload is done or expires: the fingerprint as key, the uploadId and storage key, the chunk size, and the parts with their ETags. The File record lives on the server permanently: fileId, status ready, a thumbnail URL and the owner id. The uploadId links the item to its resume record, and the fileId links it to the server file.UploadItemstore, in memorystatus, sentfile: this pagepreviewUrluploadId, fileIdResumeRecordIndexedDBfingerprint: keyuploadId, keychunkSizeparts + ETagsFileserver, by fileIdfileIdstatus: readythumbnailUrlowner idlives: this pagelives: until donelives: permanentarrows: uploadId, then fileId
Three lifetimes: the page, until the upload ends, and forever. 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.
DataLives inLifetimeWhy there
Upload items and progressExternal upload store in memoryThe pageChanges many times a second; React subscribes to it.
File objectsOn the item, in memoryThe pageA File can't be saved to localStorage; storing its bytes in IndexedDB costs disk.
Thumbnail blobs and object URLsOn the itemUntil removed or unmountedMust be revoked by hand, so they live next to the code that removes items.
Resume recordsIndexedDB object store keyed by fingerprintUntil done or expiredSurvives reloads and crashes; holds structured data and file handles.
Finished filesServer, referenced by fileIdPermanentThe host app stores only ids, not bytes.
Validation rules and limitsProps from the hostThe componentEach 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.

Wireframe of the uploader's default parts in an Attachments panel. At the top, a dashed drop zone says Drop files here, or, next to an Add files button. Below it a summary reads 1 of 3 ready, about 2 minutes left, with a Pause all button. Then four rows, each with a type badge, the name and size, a status, a progress bar and a button: beach.jpg, 3.1 MB, done, Remove. holiday.mov, 1.2 GB, uploading 45 percent, Pause. report.pdf, 2 MB, checking for viruses, Cancel. setup.exe, 9 MB, not added because of its type, Remove.AttachmentsDrop files here, orAdd files1 of 3 ready, about 2 minutes leftPause alljpgbeach.jpg, 3.1 MBdoneRemovemovholiday.mov, 1.2 GBuploading 45%Pausepdfreport.pdf, 2 MBchecking for virusesCancelexesetup.exe, 9 MBnot added: wrong typeRemove
The button is the main way in; the dashed drop zone is extra. Every row says its status in words as well as with a bar and a mark, and every button acts on one named file.

Decision How much of the uploader should the component own?

One closed component

FileUploader with 30 props: labels, colours, a variant for each product.

Pros

  • Easy to start: drop it in and it works.

Cons

  • Every new product layout adds props until nobody understands them.
  • Can't put the preview cards above the composer and the button below it.

A headless hook plus default parts

useUploader(options) returns items and actions (addFiles, pause, resume, cancel, retry, remove) and props getters for the drop zone and input. The library also ships Uploader.DropZone, Uploader.List and Uploader.Item built on the hook.

Pros

  • Teams use the default parts for a quick start and the hook for unusual layouts.
  • Logic is tested once; looks vary freely.
  • Accessibility comes built in through the props getters.

Cons

  • Two layers to document.
  • Teams that go fully custom can break accessibility the parts got right.

Render props only

FileUploader takes a children function that receives state and actions and renders everything.

Pros

  • Full control in one component.

Cons

  • No defaults: every team rebuilds the same rows.
  • Harder to share pieces like the live region.

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.

ts
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"
};
The public props. Rules and transport are data and functions the host passes in.
ts
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;
}
The transport adapter. S3 multipart, GCS, tus and test fakes all implement it.

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

http
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
Create, sign parts, complete. Our API carries JSON; storage carries bytes.

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.

AttackWhat happensDefense
Lying about the typeevil.html uploaded as image/png, then opened in the browserServer 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 scriptsAn 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 filenameA file named <img src=x onerror=alert(1)>.png is legal on macOS and LinuxRender names as text (React does this by default); never put them in innerHTML or an unescaped template.
Path tricksA name like ../../config.json or a 2,000-character nameStore objects under random keys; keep the original name only as metadata, trimmed to 255 bytes, with path separators and control characters removed.
Spoofed extensionsinvoice + the right-to-left override character U+202E + fdp.exe displays as invoiceexe.pdfStrip bidirectional control characters from names before showing or storing them.
Decompression bombsA 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 terabytesRead dimensions from the header and refuse huge ones before decoding; cap unzip sizes.
MalwareA real virus in a real PDF, shared with coworkersKeep new files in a quarantine state until a scanner passes them; that's the processing status.
Stolen or reused upload URLsA signed URL leaks into logs or analyticsSign 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 button with a visible label such as "Add files", and the limits as a description: aria-describedby pointing at "PDF, PNG or JPG, up to 25 MB". The hidden input stays out of the tab order and the accessibility tree (display: none or the hidden attribute does both, and input.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 ul labelled "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 progress element or role="progressbar" with aria-valuenow, plus aria-valuetext such as "45 percent, about 2 minutes left". In processing, drop aria-valuenow so 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.

ts
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`);
    },
  };
}
A throttled announcer: events that matter go out now, batch progress at most every 15 seconds.

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.

MinutesWhat to doWhat to draw or say
0 to 5Scope: file sizes, counts, input methods, destination, processing, resumeWrite the three numbers that drive the design: max file size, max files per drop, slowest network.
5 to 10Name the hard parts and sketch the pipelineDoors, validate, queue, transport, process. Say: "Each file is a state machine; the queue schedules them."
10 to 18State machine and queueDraw the statuses and arrows. Concurrency of 3, transient versus permanent errors, byte-weighted progress.
18 to 28Transport and large filesPre-signed URLs, XHR for progress, chunks of 8 MiB, retries with backoff, resume through IndexedDB and the server's part list.
28 to 35Component API and architectureHeadless hook plus parts, adapter interface, store outside React. Show the props.
35 to 42Security and accessibilityServer re-validation, separate download domain, SVG; button first, named row buttons, throttled live region.
42 to 45Wrap upSummarise 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?"

Think first

"Uploads keep failing on mobile when the phone locks. What do you do?"

Think first

"How do you know the uploader works well in production?"

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 addFiles function.
  • 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 createImageBitmap in 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 XMLHttpRequest for upload progress, because fetch has 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 beforeunload only 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next