Validating Dropped Files Before Upload
Run every dropped or selected file through one async validation pipeline before queueing it: reject empty entries and directories, enforce count and per-file and total size limits from file.size, sniff the real type from the first bytes rather than file.type, check image dimensions with createImageBitmap where it matters, flag duplicates by name, size and modification time, and return all problems per file in a single result so the UI can show them together — while the server still repeats every check that matters for security.
Users drop whatever is on their desktop: a folder with 300 photos, a 4 GB video when the limit is 500 MB, a .docx renamed to .pdf, the same photo twice, a zero-byte placeholder from a cloud-sync folder. Uploading first and failing on the server wastes their time and your bandwidth. Validating on drop gives an answer in milliseconds, lets them fix the batch before anything leaves the device, and reduces server load — as long as nobody mistakes client validation for security. This page is part of drag-and-drop file uploads in upload fundamentals and browser APIs. The server-side counterpart is server-side file validation.
When to use this approach
- Files arrive in batches by drag-and-drop, paste or multi-select, and some of them will not meet your rules.
- Rules go beyond extension: real type, image dimensions, duration, duplicates.
- You want users to see every problem at once, not discover them one failed upload at a time.
Prerequisites
- A list of rules agreed with the server: accepted types, size limits, maximum count, dimension limits.
- A magic-byte sniffer — the approach in detecting file type from magic bytes in JavaScript.
- A way to flatten dropped folders into files, as in handling dropped folders with the DataTransfer API.
Cheapest checks first
Implementation
export interface Rules {
maxFiles: number;
maxFileBytes: number;
maxTotalBytes: number;
accept: Set<string>; // sniffed MIME types
image?: { maxPixels: number; minWidth: number; minHeight: number };
}
export type Problem =
| { code: "empty" } | { code: "too-large"; limit: number }
| { code: "type"; detected: string | null } | { code: "duplicate" }
| { code: "dimensions"; width: number; height: number } | { code: "unreadable" };
export interface Verdict { file: File; ok: boolean; problems: Problem[]; detected: string | null }
export interface BatchResult { accepted: File[]; verdicts: Verdict[]; batchProblems: string[] }
const SIGNATURES: [string, number[], number][] = [
["image/jpeg", [0xff, 0xd8, 0xff], 0],
["image/png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0],
["image/gif", [0x47, 0x49, 0x46, 0x38], 0],
["image/webp", [0x57, 0x45, 0x42, 0x50], 8], // "WEBP" after RIFF header
["application/pdf", [0x25, 0x50, 0x44, 0x46, 0x2d], 0],
["video/mp4", [0x66, 0x74, 0x79, 0x70], 4], // "ftyp" box (also HEIC/AVIF — refine by brand)
];
async function sniff(file: File): Promise<string | null> {
const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
for (const [mime, sig, off] of SIGNATURES) {
if (sig.every((b, i) => head[off + i] === b)) {
if (mime === "video/mp4") {
const brand = String.fromCharCode(...head.slice(8, 12));
if (["heic", "heix", "mif1", "msf1"].includes(brand)) return "image/heic";
if (brand === "avif") return "image/avif";
}
return mime;
}
}
return null;
}
export async function validateBatch(files: File[], rules: Rules, alreadyQueued: File[] = []): Promise<BatchResult> {
const batchProblems: string[] = [];
const all = files.filter((f) => f.size > 0 || f.type !== ""); // drop folder placeholders
if (all.length + alreadyQueued.length > rules.maxFiles) {
batchProblems.push(`You can add up to ${rules.maxFiles} files; ${all.length + alreadyQueued.length} selected.`);
}
const seen = new Set(alreadyQueued.map((f) => `${f.name}|${f.size}|${f.lastModified}`));
const verdicts: Verdict[] = [];
let total = alreadyQueued.reduce((s, f) => s + f.size, 0);
for (const file of all) {
const problems: Problem[] = [];
const key = `${file.name}|${file.size}|${file.lastModified}`;
if (file.size === 0) problems.push({ code: "empty" });
else if (file.size > rules.maxFileBytes) problems.push({ code: "too-large", limit: rules.maxFileBytes });
if (seen.has(key)) problems.push({ code: "duplicate" });
let detected: string | null = null;
if (problems.length === 0) {
try { detected = await sniff(file); } catch { problems.push({ code: "unreadable" }); }
if (!problems.length && (!detected || !rules.accept.has(detected))) problems.push({ code: "type", detected });
}
if (!problems.length && rules.image && detected?.startsWith("image/") && detected !== "image/heic") {
try {
const bmp = await createImageBitmap(file); // one at a time: memory-bounded
const { width, height } = bmp;
bmp.close();
if (width * height > rules.image.maxPixels || width < rules.image.minWidth || height < rules.image.minHeight) {
problems.push({ code: "dimensions", width, height });
}
} catch { problems.push({ code: "unreadable" }); }
}
const ok = problems.length === 0 && total + file.size <= rules.maxTotalBytes;
if (problems.length === 0 && !ok) problems.push({ code: "too-large", limit: rules.maxTotalBytes - total });
if (ok) { total += file.size; seen.add(key); }
verdicts.push({ file, ok, problems, detected });
}
const accepted = batchProblems.length
? verdicts.filter((v) => v.ok).slice(0, Math.max(0, rules.maxFiles - alreadyQueued.length)).map((v) => v.file)
: verdicts.filter((v) => v.ok).map((v) => v.file);
return { accepted, verdicts, batchProblems };
}
export function describe(p: Problem, file: File): string {
switch (p.code) {
case "empty": return `${file.name} is empty.`;
case "too-large": return `${file.name} is ${(file.size / 1048576).toFixed(1)} MB; the limit is ${(p.limit / 1048576).toFixed(0)} MB.`;
case "type": return `${file.name} is not a supported type${p.detected ? ` (${p.detected})` : ""}.`;
case "duplicate": return `${file.name} is already in the list.`;
case "dimensions": return `${file.name} is ${p.width}×${p.height}; please use a larger or smaller image.`;
case "unreadable": return `${file.name} could not be read.`;
}
}
Line-by-line on the decisions that matter
- All files get a verdict. Stopping at the first bad file forces users into a fix-one-retry loop. Collecting every problem lets the UI show a single list: “3 files can’t be added” with reasons.
- Metadata checks first, per file. Size, emptiness and duplicates need no I/O. A 4 GB video fails in microseconds without its bytes ever being touched.
- Sniffing 16 bytes.
file.slice(0, 16).arrayBuffer()reads only the header. Theftypbrand distinguishes MP4 from HEIC and AVIF, which share the container. - Decoding one image at a time.
createImageBitmapfully decodes the image; doing a 300-photo batch in parallel would allocate gigabytes. Sequential decoding keeps memory to one image. For dimensions only, a header parser (reading width and height from the first kilobytes) is cheaper still. - Folder placeholders. Dropping a folder in some browsers yields a
Filewith size 0 and empty type for the folder itself. Filtering those avoids a confusing “empty file” error for something the user did not think of as a file. - Duplicates by name, size and
lastModified. Cheap and good enough to catch the same file dropped twice. True content deduplication needs a hash, which is the server’s job — see deduplicating uploads with content hashes.
Showing the result
What client validation cannot do
Every check above can be bypassed by anyone who opens DevTools or calls your upload endpoint directly. That is fine — they exist to save honest users time — but it means the server must enforce every rule that protects the system: size limits at the proxy and in the handler, type checks on the bytes that actually arrived, dimension and pixel-count limits before decoding, rate limits, virus scanning. Keep the rules in one shared configuration (a JSON document served to the client and loaded by the server) so the two sides cannot drift: a client that allows 25 MB while the server allows 20 MB produces uploads that pass validation and then fail with a 413, which is the worst of both.
There are also checks the client should not attempt. Malware scanning in the browser is impossible to do meaningfully. Deep format validation — is this PDF well formed, does this video decode end to end — needs server tools. And anything that depends on your data (quotas, duplicate detection across a user’s library) belongs to the server, which can return a precise refusal for the client to display. The client’s job is to catch the common, cheap-to-detect mistakes before they cost anyone a minute of upload time.
Configuration gotchas
NotReadableError when sniffing. The file was removed or changed after selection, or it is a cloud placeholder (OneDrive, iCloud) that the OS has not downloaded. Report it as unreadable and suggest making the file available offline.
createImageBitmap rejects valid HEIC files. Chrome and Firefox cannot decode HEIC. Skip dimension checks for HEIC (as the code does) or convert first, as in converting HEIC images to JPEG in the browser.
Validation freezes the page on large batches. Hundreds of image decodes on the main thread block rendering. Move the pipeline into a worker, or yield between files with await new Promise(requestAnimationFrame) and show a “checking files” indicator.
Limits disagree with the server. The client says 25 MB, nginx says 20 MB. Serve limits from one config endpoint that both sides read.
Where time goes for a 300-photo drop
Verification
import { strict as assert } from "node:assert";
const rules: Rules = { maxFiles: 10, maxFileBytes: 20 * 1048576, maxTotalBytes: 100 * 1048576,
accept: new Set(["image/jpeg", "image/png"]) };
const png = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0])], "a.png");
const fakePdf = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "b.pdf");
const empty = new File([], "c.jpg", { type: "image/jpeg" });
const r = await validateBatch([png, fakePdf, empty, png], rules);
assert.equal(r.accepted.length, 2, "png and the PNG-named-pdf pass by content");
assert.ok(r.verdicts[2].problems.some((p) => p.code === "empty"));
assert.ok(r.verdicts[3].problems.some((p) => p.code === "duplicate"));
console.log(r.verdicts.map((v) => `${v.file.name}: ${v.ok ? "ok" : v.problems.map((p) => p.code).join(",")}`));
Frequently Asked Questions
Should the extension matter at all?
Use it only as a hint for display and as a tiebreaker for text formats that have no magic bytes (CSV, plain text). For binary formats, the bytes decide; a mismatched extension can be corrected on the server when you store the file.
Can I validate video duration on the client?
Yes: load the file into a <video> element via an object URL and read duration after loadedmetadata. It is quick for common formats and useful for duration limits, but it decodes nothing, so it cannot prove the video is playable.
What if the server rejects a file the client accepted?
Show the server’s reason on that file and keep the others. Treat it as a sign that the shared rules drifted and fix the configuration, rather than adding special cases on either side.