web-files-file-upload-patterns · git:20260906.5c10830 · 2026-09-06 · sha256 a6e4845afd6dd84c
web-files-file-upload-patterns git:20260906.5c10830A
Immutable. This exact content is served forever at /api/v1/blob/a6e4845afd6dd84c.
---
name: web-files-file-upload-patterns
description: File upload patterns - drag-drop dropzones, chunked and resumable uploads, presigned URL flows, file validation (MIME type, magic bytes), progress tracking, accessibility (ARIA)
---
# File Upload Patterns
> **Quick Guide:** A dropzone is a keyboard-operable button wrapping a hidden file input, with drag
> as an enhancement. Validate for the user's benefit on the client — extension, MIME type, then the
> file's own magic bytes — and again on the server, because none of the client checks are security.
> Progress needs `XMLHttpRequest`; `fetch` has no upload progress event. Past roughly 100MB, chunk
> the file so a failure costs one chunk. Large files go straight to storage on a presigned URL the
> server issues, so no request body is ever proxied.
**Detailed Resources:**
- [examples/core.md](examples/core.md) — file input, dropzone, file list state and rendering, the assembled component
- [examples/validation.md](examples/validation.md) — rule-based validator, magic-byte detection, dimension checks, a validation hook
- [examples/progress.md](examples/progress.md) — XHR progress with speed and ETA, progress bar, formatters, concurrent uploads
- [examples/preview.md](examples/preview.md) — a preview thumbnail for a selected file, with cleanup
- [examples/presigned-upload.md](examples/presigned-upload.md) — PUT and POST-policy uploads, the server contract, multipart parts, the whole flow as a hook
- [examples/resumable.md](examples/resumable.md) — chunked uploader with retry, resume across a reload, a tus client, the tus server contract
- [examples/accessibility.md](examples/accessibility.md) — announcing selection and progress, focus return after the file dialog
- [reference.md](reference.md) — method selection by size, expiry guidance, validation order, CORS, review checklist
---
## Which path applies
The destination decides almost everything else.
- **The file goes to your own endpoint** — one `POST` with `FormData`, progress from XHR, and a size
cap the server can enforce. [examples/core.md](examples/core.md) and
[examples/progress.md](examples/progress.md) are the whole of it.
- **The file goes to object storage** — the server issues a presigned URL and the browser uploads to
it directly, so no bytes pass through your application.
[examples/presigned-upload.md](examples/presigned-upload.md).
- **The file is large enough that a failure hurts** — split it, upload the parts with a concurrency
limit, and record which parts landed so a retry resumes.
[examples/resumable.md](examples/resumable.md).
---
<critical_requirements>
## Before writing upload code
**Validate on the server as well as in the browser.** Client validation exists to tell the user
quickly what will be rejected; anyone can skip it entirely, so it settles nothing about safety.
**Read the file's first bytes when the type matters.** Extensions and MIME types are both supplied
by whoever made the file, and a renamed executable passes every check that trusts them.
**Revoke every object URL you create.** A preview holds the whole file in memory until
`URL.revokeObjectURL()` runs, so a user who changes their mind three times leaks three files.
**Make the dropzone reachable from the keyboard.** `role="button"`, `tabIndex={0}` and an
Enter/Space handler that opens the file dialog, with drag layered on top — mobile has no drag at
all, so the click path is the real one.
**Have the server issue a short-lived presigned URL rather than proxying the body.** The upload then
costs your application nothing, and no storage credential is ever in reach of the browser.
</critical_requirements>
---
**Auto-detection:** dropzone, dataTransfer.files, dragenter, dragleave, dragover, input type="file",
event.target.files, accept attribute, xhr.upload.addEventListener, lengthComputable, presigned URL,
uploadUrl, multipart upload, UploadPart, ETag, chunked upload, file.slice, Content-Range, resumable
upload, tus, Tus-Resumable, Upload-Offset, magic bytes, file signature, FormData append file
**Applies to:**
- Selecting files by click, keyboard or drag
- Validating type, size and dimensions before anything is sent
- Reporting progress, speed and remaining time, and cancelling
- Uploading straight to storage on a URL the server signed
- Splitting a large file into chunks and resuming an interrupted upload
- Announcing selection, progress and failure to a screen reader
**Handled elsewhere:**
- Receiving, scanning and storing the bytes once they arrive
- Resizing, cropping or converting an image before it is sent — this skill sends the `File` it is
given
- Where the stored object lives, how it is served, and what its URL looks like
- Streaming playback of media that was uploaded
---
<philosophy>
## Philosophy
An upload is three independent problems that get conflated: choosing a file, checking it, and moving
its bytes. Keeping them separate is what makes any of them replaceable.
The checking half has a rule that never bends. **Client validation is a user-experience feature, and
the server's is the only one that is a control.** Everything the browser knows about a file — its
name, its extension, its `type` — came from the file itself. Reading magic bytes raises the bar but
does not change the category: it is still a check the client can be made to skip.
The moving half scales by a different axis: not how many files, but how long a single request is
open. A short request can fail and be retried whole. A long one accumulates the probability of a
dropped connection until retrying whole is unacceptable, and that is the point at which chunking
starts paying for its complexity — not at a particular byte count.
</philosophy>
---
<patterns>
## Core patterns
### Pattern 1: Dropzone
Count drag events rather than tracking a boolean. `dragenter` and `dragleave` fire for every nested
element, so a boolean flickers off the moment the pointer crosses a child.
```typescript
const dragCounterRef = useRef(0);
<div
onDragEnter={() => { dragCounterRef.current++; setState("drag-over"); }}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setState("idle");
}}
onDragOver={(e) => e.preventDefault()} // without this, drop never fires
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label="File upload area. Click or drag files to upload."
>
<input ref={inputRef} type="file" hidden aria-hidden="true" tabIndex={-1} />
</div>
```
Full code: [examples/core.md](examples/core.md)
### Pattern 2: File list state
One entry per file with its own status, so a failure is per-file rather than per-batch. Rejections
come back with reasons the UI can show.
```typescript
interface FileWithId {
id: string;
file: File;
preview?: string;
status: "pending" | "uploading" | "success" | "error";
progress: number;
error?: string;
}
// addFiles returns { added, rejected }, each rejection carrying its reason
// removeFile and clearFiles revoke any preview URL before dropping the entry
```
Full code: [examples/core.md](examples/core.md)
### Pattern 3: Progress with XHR
`fetch` reports download progress and not upload progress, so upload progress means
`XMLHttpRequest`. Average the last few samples or the speed reading jitters unusably.
```typescript
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return; // no total: show a spinner, not a bar
const speed = rollingAverageSpeed(event.loaded, performance.now());
setProgress({
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
speed,
remainingTime: (event.total - event.loaded) / speed,
});
});
```
`xhr.abort()` is the cancel. Streaming a `fetch` body measures bytes you handed the stream rather
than bytes on the wire, which is why it is not a substitute.
Full code: [examples/progress.md](examples/progress.md)
### Pattern 4: Magic-byte detection
Read the first twelve bytes and compare against known signatures. Never read the whole file — a
large one freezes the tab.
```typescript
const FILE_SIGNATURES = [
{ mime: "image/jpeg", extension: "jpg", signature: [0xff, 0xd8, 0xff] },
{ mime: "image/png", extension: "png", signature: [0x89, 0x50, 0x4e, 0x47] },
{
mime: "application/pdf",
extension: "pdf",
signature: [0x25, 0x50, 0x44, 0x46],
},
{
mime: "application/zip",
extension: "zip",
signature: [0x50, 0x4b, 0x03, 0x04],
},
];
const buffer = await file.slice(0, 12).arrayBuffer();
const bytes = new Uint8Array(buffer);
```
Office documents are ZIP archives, so a ZIP match needs a second look: `word/`, `xl/` or `ppt/` in
the first kilobyte identifies which.
Full code: [examples/validation.md](examples/validation.md)
### Pattern 5: Presigned upload
Four steps, and your application never holds the bytes:
1. The client asks your server for a URL, sending name, type and size.
2. The server authorises the request, sanitises the name, builds a key, and signs a short-lived URL.
3. The client `PUT`s the file to that URL.
4. The client tells your server the key, and the server records it.
```typescript
const { uploadUrl, key } = await requestPresignedUrl(file);
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.addEventListener("progress", reportProgress);
xhr.send(file);
```
A POST-policy URL instead of a PUT lets the storage service enforce size and content-type itself —
at the cost of `FormData` field order mattering, with the file appended last.
Full code: [examples/presigned-upload.md](examples/presigned-upload.md)
### Pattern 6: Chunked and resumable
Slice the file, upload the slices with a concurrency limit, and retry a failed slice with
exponential backoff rather than restarting.
```typescript
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const start = chunkIndex * chunkSize;
const chunk = file.slice(start, Math.min(start + chunkSize, file.size));
```
Persist the completed chunk indexes keyed on `name-size-lastModified`, so a reload resumes rather
than restarts, and expire that record after a day. For an interoperable protocol rather than your
own, tus is `POST` to create, `HEAD` to learn the offset, `PATCH` to append.
Full code: [examples/resumable.md](examples/resumable.md)
</patterns>
---
<red_flags>
## Red flags
**Breaks at runtime:**
- No `event.preventDefault()` on `dragover` — `drop` never fires and the browser navigates to the
file instead — prevent the default on both `dragover` and `drop`
- A boolean for drag state — it flickers as the pointer crosses child elements — count `dragenter`
and `dragleave` in a ref
- The file input's value left set after a selection — choosing the same file twice fires no
`change` event — assign `event.target.value = ""` after reading `files`
- `fetch` used where progress is required — there is no upload progress event and stream progress
measures the wrong thing — use `XMLHttpRequest`
- `file.text()` or `readAsDataURL()` to inspect a type — the whole file is read into memory — slice
the first 12 bytes
- An object URL created in a render body — a new one per render, none revoked — create it in an
effect and revoke in the cleanup
- No CORS configuration on the storage bucket — a direct upload fails preflight — allow the origin
and the methods, and expose `ETag` for multipart
**Surprising behaviour:**
- A presigned URL is a bearer token: whoever holds it can perform that operation until it expires
- Extension, MIME type and `File.type` all come from the client and are all forgeable
- Mobile browsers have no drag and drop, so the click path is the only path there
- `lengthComputable` is false for a request with no known length, and the percentage is meaningless
until it is true
- A multipart upload that is neither completed nor aborted leaves parts billed and invisible; set a
lifecycle rule to expire them
- Safari's drag events differ enough from Chromium's to be worth testing separately
- A photo carries EXIF orientation that the browser applies on display and canvas processing does
not
</red_flags>