A size or type limit enforced only by the server is discovered after the upload — which for a 150 MB phone video on a mobile connection means minutes of waiting followed by “File too large”, and for a .pdf that is really a renamed Word document means a confusing processing failure an hour later.
The file upload fields lifecycle puts local validation between selected and uploading. Everything about a file that matters for acceptance — its size, its real format, an image’s pixel dimensions — can be read locally in milliseconds. This page implements those checks in order of cost, with messages that tell the user exactly which file failed and what to do.
Context and prerequisites
Three sources of type information exist, with very different reliability:
- The
acceptattribute on the input filters the file picker. It is a hint to the picker, not a validation: users can switch the picker to “All files”, and drag-and-drop ignores it entirely. file.typeis the MIME type the browser guessed from the file name extension (and the operating system’s registry). A file renamed from.docxto.pdfreportsapplication/pdf. Some files report an empty string.- The first bytes of the file — its “magic number” — identify most formats reliably:
%PDF-for PDF,89 50 4E 47for PNG,FF D8 FFfor JPEG,50 4B 03 04for ZIP-based formats (including DOCX and XLSX).
Size is exact (file.size in bytes) and free. Image dimensions require decoding the header, which createImageBitmap does quickly without drawing anything. Run the checks cheapest first and stop at the first failure.
The core pattern: ordered checks returning a specific reason
export interface FileRules {
maxBytes: number;
types: { mime: string; ext: string[]; magic: number[][] }[]; // allowed formats
image?: { minWidth?: number; minHeight?: number; maxPixels?: number };
}
export const PDF = { mime: "application/pdf", ext: [".pdf"], magic: [[0x25, 0x50, 0x44, 0x46, 0x2d]] };
export const PNG = { mime: "image/png", ext: [".png"], magic: [[0x89, 0x50, 0x4e, 0x47]] };
export const JPEG = { mime: "image/jpeg", ext: [".jpg", ".jpeg"], magic: [[0xff, 0xd8, 0xff]] };
const fmtBytes = (n: number) =>
n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(n >= 10 * 1024 * 1024 ? 0 : 1)} MB` : `${Math.ceil(n / 1024)} KB`;
export async function checkFile(file: File, rules: FileRules): Promise<string | null> {
// 1. Size: exact and free. Check first so a huge file is never read.
if (file.size === 0) return `${file.name} is empty.`;
if (file.size > rules.maxBytes) {
return `${file.name} is ${fmtBytes(file.size)}. Files must be ${fmtBytes(rules.maxBytes)} or smaller.`;
}
// 2. Extension and declared type: cheap, catches most honest mistakes.
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
const byExt = rules.types.find((t) => t.ext.includes(ext));
const allowedList = rules.types.map((t) => t.ext[0].slice(1).toUpperCase()).join(", ");
if (!byExt) return `${file.name} is not a supported file type. Use ${allowedList}.`;
// 3. Signature: read only the first bytes; slice() does not load the file.
const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
const matches = (sig: number[]) => sig.every((b, i) => head[i] === b);
if (!byExt.magic.some(matches)) {
return `${file.name} does not look like a ${byExt.ext[0].slice(1).toUpperCase()} file. It may have been renamed; open and re-save it, then try again.`;
}
// 4. Image dimensions: decode the header only when rules ask for it.
if (rules.image && byExt.mime.startsWith("image/")) {
let bmp: ImageBitmap | undefined;
try {
bmp = await createImageBitmap(file);
const { width, height } = bmp;
const r = rules.image;
if ((r.minWidth && width < r.minWidth) || (r.minHeight && height < r.minHeight)) {
return `${file.name} is ${width}×${height} pixels. Images must be at least ${r.minWidth ?? 0}×${r.minHeight ?? 0}.`;
}
if (r.maxPixels && width * height > r.maxPixels) {
return `${file.name} is too large in dimensions (${width}×${height}). Resize it and try again.`;
}
} catch {
return `${file.name} could not be read as an image. It may be damaged.`;
} finally {
bmp?.close(); // release decoded memory immediately
}
}
return null;
}
Step-by-step walkthrough
- Set
acceptfor the picker, but do not rely on it.accept=".pdf,image/png,image/jpeg"narrows the default view; drag-and-drop and “All files” bypass it, so the same rules run in code. - Check size before reading anything.
file.sizeis exact and costs nothing; a 2 GB file should be rejected before a single byte is read. - Check the extension against an allow-list. This catches the common mistake (a
.heicphoto where JPEG is needed) with a message that lists what is accepted. - Read the first bytes and match signatures.
file.slice(0, 16)creates a view without loading the file;arrayBuffer()on the slice reads 16 bytes. - Decode image headers only when dimensions matter.
createImageBitmapis fast and off-main-thread in most browsers; alwaysclose()the bitmap. - Return one specific message naming the file. “photo.heic is not a supported file type. Use PDF, PNG, JPG.” Put it on the file’s item in the list, and in the error summary, following writing error messages that tell the reader what to do.
Failure modes and edge cases
1. file.type is empty
Files with unusual extensions, and some files from cloud-storage pickers, report type === "". Do not reject on an empty type; rely on the extension and signature instead.
2. HEIC photos from iPhones
iOS may convert HEIC to JPEG when a web page’s accept lists only JPEG, but not in every picker path, and files transferred to a desktop stay HEIC. Either accept HEIC and convert server-side, or reject with a message that explains how to export as JPEG.
3. Multiple files, one failure
When five files are dropped and one fails, keep the four that pass and show the one failure on its own item. Rejecting the whole drop makes the user re-select four good files.
4. The same message for every limit
“Invalid file” tells the user nothing. Size, type, damage and dimensions each need their own sentence, and each should include the actual value and the limit, so the user knows how far off they are.
5. Validation that blocks the main thread
Reading a whole large file with FileReader.readAsDataURL to “check” it can freeze the page and multiply memory use. Read only the bytes you need with slice, and never base64-encode a file just to validate it — previews are covered in image previews with object URLs without leaks.
Verification checklist
Frequently Asked Questions
Is checking magic bytes a security measure?
It is a usability measure that happens to stop accidental mistakes. A malicious user controls the bytes and can bypass your JavaScript altogether. The server must validate content again, ideally by decoding the file in a sandbox, as the file upload fields topic describes.
Should size limits use 1000 or 1024?
Use the same base the server enforces and display the limit in the same unit, so “20 MB” means the same number on both sides. Operating systems disagree (macOS shows decimal megabytes, Windows binary), so a message that says “31 MB, limit 20 MB” is clear even if the user’s file manager shows a slightly different figure.
Can I compress images on the client instead of rejecting them?
Yes, and for avatars and photos it is often kinder: draw the image to a canvas at a smaller size and export it with canvas.toBlob(cb, "image/jpeg", 0.85). Tell the user it was resized, and keep the original if the resized version is still too large.