Enforcing File Size Limits Before Upload
How do you stop a user from waiting through a two-minute upload of a 40 MB video only for the server to answer “413 Payload Too Large”? Check file.size the moment the file is chosen. This recipe enforces a per-file limit and a total limit across several files, defines both once in bytes and shares them with the server, formats sizes in the units your copy uses, and reports failures through setCustomValidity() on the file input — the same Constraint Validation API path every other field on the site uses. It also covers what to tell users when a file is too large, which is where most size errors fail.
When to Enforce Size Limits in the Browser
Always, for any upload with a server-side limit — which is every upload, because every server has one whether you configured it or not. The browser check is free: file.size is available synchronously the instant a file is picked, with no reading required. It matters most when:
- Files are large relative to the connection. Mobile uploads of photos and videos are the classic case.
- Several files share one request. A per-file limit alone lets five 9 MB files through a 20 MB request limit.
- The server limit is enforced by infrastructure (a proxy, a CDN, a serverless platform) that returns an opaque error page rather than a field-level message.
Size checks are one stage of the pipeline in the file upload validation topic; they run before the more expensive magic byte check, because there is no point sniffing a file you are going to reject for size anyway.
Minimal Working Size Validator
// shared/upload-limits.ts — imported by the client and the server
export const LIMITS = {
perFileBytes: 10 * 1024 * 1024, // 10 MiB
totalBytes: 25 * 1024 * 1024, // 25 MiB per submission
minBytes: 1, // reject empty files
} as const;
const UNITS = ["bytes", "KB", "MB", "GB"] as const;
/** Formats bytes using binary multiples, labelled the way most users read them. */
export function formatSize(bytes: number): string {
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < UNITS.length - 1) {
value /= 1024;
unit++;
}
const digits = unit === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${UNITS[unit]}`;
}
export function sizeErrors(files: File[], limits = LIMITS): string[] {
const errors: string[] = [];
for (const f of files) {
if (f.size < limits.minBytes) errors.push(`${f.name} is empty. Choose the file again or pick a different one.`);
else if (f.size > limits.perFileBytes) {
errors.push(`${f.name} is ${formatSize(f.size)}. Each file must be ${formatSize(limits.perFileBytes)} or smaller.`);
}
}
const total = files.reduce((sum, f) => sum + f.size, 0);
if (total > limits.totalBytes) {
errors.push(`Your files add up to ${formatSize(total)}. The total limit is ${formatSize(limits.totalBytes)}; remove some files.`);
}
return errors;
}
// Wiring to the input — synchronous, so it can run on every change without a spinner.
const input = document.querySelector<HTMLInputElement>("#attachments")!;
const list = document.querySelector<HTMLUListElement>("#attachments-err")!;
const summary = document.querySelector<HTMLElement>("#attachments-total")!;
input.addEventListener("change", () => {
const files = [...(input.files ?? [])];
const errors = sizeErrors(files);
input.setCustomValidity(errors[0] ?? "");
input.toggleAttribute("aria-invalid", errors.length > 0);
list.replaceChildren(...errors.map((e) => Object.assign(document.createElement("li"), { textContent: e })));
list.hidden = errors.length === 0;
const total = files.reduce((s, f) => s + f.size, 0);
summary.textContent = files.length ? `${files.length} file(s), ${formatSize(total)} of ${formatSize(LIMITS.totalBytes)}` : "";
});
input.form!.addEventListener("submit", (event) => {
if (!input.form!.checkValidity()) {
event.preventDefault();
input.form!.reportValidity();
}
});
The running total (“3 files, 18 MB of 25 MB”) is not an error; it is guidance that makes the total limit visible before it is crossed. Put it in the field’s description so it is read with the input.
Size Limit Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
perFileBytes |
number |
10 * 1024 * 1024 |
Largest single file accepted |
totalBytes |
number |
25 * 1024 * 1024 |
Largest combined size per submission |
minBytes |
number |
1 |
Rejects zero-byte files from failed downloads or cloud placeholders |
formatSize units |
binary (1024) | KB / MB / GB labels | Matches how most operating systems display sizes |
| Running total text | string | “n file(s), x of y” | Makes the total limit visible before it is exceeded |
| Proxy body limit | server config | total + ~1 MB | Multipart boundaries and other fields add overhead |
A note on units: operating systems disagree. Windows shows binary sizes labelled KB/MB; macOS shows decimal sizes labelled KB/MB. A 10,400,000-byte file is “9.9 MB” on Windows and “10.4 MB” on macOS. Whatever you choose, the limit must be computed in bytes from one constant, and the message should show the file’s size in the same formatting as the limit so the comparison is internally consistent.
Verification Steps
import { describe, it, expect } from "vitest";
import { sizeErrors, formatSize, LIMITS } from "./upload-limits";
const fake = (name: string, bytes: number) => new File([new Uint8Array(bytes)], name);
describe("sizeErrors", () => {
it("accepts a file exactly at the limit", () => {
expect(sizeErrors([fake("a.pdf", LIMITS.perFileBytes)])).toEqual([]);
});
it("names the file and both sizes when over", () => {
expect(sizeErrors([fake("big.pdf", LIMITS.perFileBytes + 1)])[0]).toMatch(/big\.pdf is 10\.0 MB.*10\.0 MB or smaller/);
});
it("enforces the total", () => {
const files = [1, 2, 3].map((i) => fake(`f${i}.pdf`, 9 * 1024 * 1024));
expect(sizeErrors(files)).toEqual([expect.stringMatching(/add up to 27\.0 MB/)]);
});
it("formats sizes", () => expect(formatSize(1536)).toBe("1.5 KB"));
});
Edge Cases and Failure Modes
Rounding makes an over-limit file look within the limit. A file of 10,485,761 bytes is one byte over a 10 MiB limit but formats as “10.0 MB”, producing “big.pdf is 10.0 MB. Each file must be 10.0 MB or smaller.” That reads as a bug. When a file is over the limit but rounds to it, show one more decimal place, or say “just over”.
Cloud placeholders report their real size but have no bytes. On some platforms, files stored only in the cloud report a normal size but fail to read. Size checks pass; the later signature read fails. Handle NotReadableError in the read step with “This file isn’t downloaded to your device yet”.
Compressed uploads. If the client compresses images before upload, the size limit that matters is the compressed size. Either check after compression, or tell users the raw limit is higher because files are shrunk first.
Infrastructure limits smaller than yours. Serverless platforms and proxies often cap request bodies at a few megabytes by default. Test an at-limit upload through the real deployment path; a limit that only exists in your code is not the limit users hit.
Telling Users What to Do About a Large File
“File too large” is a dead end. Users rarely know how to shrink a PDF or a photo, so the message should name the file, give both sizes, and — where you can — suggest the fix that works for that type. For images, the most helpful fix is to do it for them: resize client-side with a canvas to your maximum useful dimensions, and only reject if the result is still too big. For PDFs, suggest exporting “for web” or splitting the document. For videos, offer a link-based alternative such as sharing from a video service. The wording principles are the same as for every other field and are laid out in writing clear inline error message copy.
async function shrinkImage(file: File, maxEdge = 2560, quality = 0.85): Promise<File> {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale));
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality });
return new File([blob], file.name.replace(/\.\w+$/, ".jpg"), { type: "image/jpeg" });
}
Shrinking changes the file the user chose, so say so next to the file name — “Resized to 2560 px to fit the upload limit” — and keep the original if they prefer to try again with a different file.
Keeping Client and Server Limits Identical
The most common size bug is not missing validation; it is two different limits. The client says 10 MB using 1000 * 1000, the server config says 10mb which its parser interprets as 1024 * 1024, and the proxy in front says 8 MB because nobody changed its default. Files in the gaps produce inconsistent, confusing failures. Put the numbers in one module, import it in both bundles, generate the proxy configuration from it at deploy time, and add an integration test that uploads a file one byte under and one byte over the limit through the deployed stack. The broader pattern of sharing rules across the network boundary is covered in sharing Zod schemas in a monorepo.
Frequently Asked Questions
Can I limit file size with an HTML attribute?
No. There is no native maximum-size attribute for file inputs. Check file.size in a change handler and call setCustomValidity() on the input when a file is too large.
Should I use 1000 or 1024 for megabytes?
Pick one, define the limit once in bytes in a shared module, and format sizes consistently. Binary (1024) matches how Windows displays sizes; what matters most is that the client, server and proxy use the same byte count.
Is a per-file size limit enough?
Not when several files go in one request. Also enforce a total limit, or several files that each pass can exceed the server's request body limit together.
What if the user's file is too large?
Name the file, give its size and the limit, and suggest a fix. For images, consider resizing in the browser automatically before rejecting.
Related Guides
- File Upload Validation — where size checks sit in the upload pipeline.
- Validating Image Dimensions Client-Side — the pixel limits that often accompany byte limits.
- Handling Server Validation Errors After Submit — mapping a 413 back onto the file input.
- Reading ValidityState Flags for Granular Errors — how customError surfaces alongside native flags.