File upload patterns - drag-drop dropzones, chunked/resumable uploads, S3 presigned URLs, file validation (MIME type, magic bytes), progress tracking, image preview, accessibility (ARIA)
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;fetchhas 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:
The destination decides almost everything else.
POST with FormData, progress from XHR, and a size
cap the server can enforce. examples/core.md and
examples/progress.md are the whole of it.<critical_requirements>
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:
Handled elsewhere:
File it is
givenAn 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>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.
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
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.
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
fetch reports download progress and not upload progress, so upload progress means
XMLHttpRequest. Average the last few samples or the speed reading jitters unusably.
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
Read the first twelve bytes and compare against known signatures. Never read the whole file — a large one freezes the tab.
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
Four steps, and your application never holds the bytes:
PUTs the file to that URL.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
Slice the file, upload the slices with a concurrency limit, and retry a failed slice with exponential backoff rather than restarting.
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
</patterns><red_flags>
Breaks at runtime:
event.preventDefault() on dragover — drop never fires and the browser navigates to the
file instead — prevent the default on both dragover and dropdragenter
and dragleave in a refchange event — assign event.target.value = "" after reading filesfetch used where progress is required — there is no upload progress event and stream progress
measures the wrong thing — use XMLHttpRequestfile.text() or readAsDataURL() to inspect a type — the whole file is read into memory — slice
the first 12 bytesETag for multipartSurprising behaviour:
File.type all come from the client and are all forgeablelengthComputable is false for a request with no known length, and the percentage is meaningless
until it is true</red_flags>
npx skills add agents-inc/web-files-file-upload-patterns下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer