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

  1. A list of rules agreed with the server: accepted types, size limits, maximum count, dimension limits.
  2. A magic-byte sniffer — the approach in detecting file type from magic bytes in JavaScript.
  3. A way to flatten dropped folders into files, as in handling dropped folders with the DataTransfer API.

Cheapest checks first

Validation stages ordered by cost Validation runs in order of cost. Count, size and duplicate checks use metadata only and cost nothing. Type sniffing reads the first 16 bytes. Dimension checks decode image headers or the image. Each file stops at its first failing stage but all files are checked, so the user sees every problem at once. Metadata, then a few bytes, then a decode batch rules count, total size ~0 ms file metadata empty, size, dupes ~0 ms magic bytes read 16 bytes < 1 ms each decode image dimensions 10–200 ms A file stops at its first failing stage; the batch continues, so every file gets a verdict. Decoding runs only for files that passed everything cheaper — and one at a time. Client checks are for the user's benefit; the server repeats the ones that protect you.
Ordering checks by cost keeps validation of a 300-file drop under a second on most machines.

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. The ftyp brand distinguishes MP4 from HEIC and AVIF, which share the container.
  • Decoding one image at a time. createImageBitmap fully 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 File with 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

Presenting a validated batch After dropping twelve files, nine are added to the queue and three are listed under a single summary line with a specific reason each: one too large, one wrong type, one duplicate. The summary is announced once in a status region. One summary, specific reasons, good files keep going queued (9) kitchen.jpg · 2.1 MB · ready hall.jpg · 1.8 MB · ready …and 7 more, uploading 3 files couldn't be added tour.mov — 2.4 GB; limit 500 MB plan.docx — not a supported type hall.jpg — already in the list announced once via role="status" Never block the good files on the bad ones — accept what passes and explain the rest.
The user fixes three files, not twelve, and the nine good ones have already started.

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

Validation time for 300 phone photos Metadata checks take about 2 milliseconds for all 300 files, sniffing takes about 90 milliseconds, and full decodes for dimension checks take about 12 seconds; reading dimensions from headers instead takes about 150 milliseconds. 300 phone photos: validation time by stage metadata 2 ms magic bytes 90 ms full decode ≈ 12 s header dimensions 150 ms
Everything except a full decode is effectively free; if you need dimensions for big batches, read them from headers.

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.