Works with any backend npm install Zero dependencies

Client-side validation, honestly

Browser-side checks make an upload feel instant and considerate. Here is how to do each one properly — and exactly what it is worth.

Extension and size: instant, cheap, worth it

Both are available the moment a file is selected, before a single byte moves. Rejecting here turns a four-minute wait followed by an error into an immediate, specific message.

new MultipleUpload('#uploader', {
    allowedExtensions: '.jpg,.png,.pdf',
    maxFileSize: 25 * 1024 * 1024,
    minFileSize: 1,                       // catch 0-byte files early
    onError: (err) => showMessage(err.message)
});

Compare the last extension — invoice.pdf.exe is an executable — and compare case-insensitively.

Magic bytes: what the file actually is

The extension is a claim about the file; the first few bytes are evidence. Reading them in the browser is fast because you only need the head of the file:

async function sniff(file) {
    const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
    const hex  = [...head].map(b => b.toString(16).padStart(2, '0')).join('');
    if (hex.startsWith('ffd8ff'))   return 'image/jpeg';
    if (hex.startsWith('89504e47')) return 'image/png';
    if (hex.startsWith('25504446')) return 'application/pdf';
    if (hex.startsWith('504b0304')) return 'application/zip';   // also docx/xlsx
    return null;
}

Or just switch it on:

new MultipleUpload('#uploader', {
    allowedExtensions: '.jpg,.png,.pdf',
    validateMimeByMagic: true
});

Two honest caveats. Office documents and many archives are all ZIP containers, so a signature check cannot tell .docx from .xlsx — look inside for that. And a signature proves how a file starts: a polyglot can be a valid PNG and something else entirely at the same time.

Image dimensions and shape

For avatars and product images, pixel dimensions matter more than bytes. The browser can measure them without uploading anything:

new MultipleUpload('#uploader', {
    minImageWidth: 200,  minImageHeight: 200,
    maxImageWidth: 6000, maxImageHeight: 6000,
    aspectRatio: 1,                      // square only
    imageResize: { maxWidth: 2000, maxHeight: 2000, quality: 0.85 }
});

Resizing before upload is often the single biggest win available: a modern phone photo is 4–8 MB, and a 2000 px version is a few hundred kilobytes. On a mobile connection that is the difference between a two-second upload and a thirty-second one.

Duplicates by content

Hashing the file gives you real duplicate detection and an integrity check the server can verify. Do it in a Web Worker — hashing a gigabyte on the main thread freezes the tab:

new MultipleUpload('#uploader', {
    computeHash: true,
    hashAlgorithm: 'sha256',      // runs in a worker
    preventDuplicates: true
});

The server should treat a client-supplied hash as a hint. If the hash is authoritative for you — de-duplication, content addressing — recompute it server-side.

Custom rules

new MultipleUpload('#uploader', {
    onSelect: (files) => files.filter(f => {
        if (!/^INV-\d{6}/.test(f.name)) {
            showMessage(`${f.name}: expected INV-###### naming`);
            return false;
        }
        return true;
    })
});

Order your checks by cost

Extension first (free), then size (free), then magic bytes (reads a few bytes), then image dimensions (decodes a header), then hashing (reads the whole file). Every file rejected at step one is work you never do at step five.

And once more, because it matters: everything above runs on hardware the user controls. Re-check anything you care about on the server. See upload security.