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.

Time wasted before a size rejection A bar chart of how long a user waits before learning a 40 MB file is too large, depending on where the size limit is enforced, on a 5 Mbps mobile upload. Proxy rejects after upload 64 seconds Server streams and aborts at limit 16 seconds Browser checks file.size on change 0.01 seconds
A server-only limit makes the user wait for the whole upload; a browser check answers instantly.

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.

One limit, three enforcement points A single byte limit defined in a shared module is used by the browser check, the server's streaming parser and the proxy configuration. Shared constant perFileBytes = 10 MiB Browser file.size check on change Server parser maxFileSize while streaming Proxy / platform body limit ≥ total + overhead
Defining the limit once, in bytes, is what prevents a band of files that pass the browser and fail the server.

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.

Responding to an oversized file A decision tree choosing between resizing automatically, suggesting a fix, or rejecting when a chosen file exceeds the size limit. Is the oversized file an image? yes Does resizing bring it under the limit? yes Resize, tell the user no Reject with sizes + tip no Is it a PDF? yes Suggest export for web or split no Reject with sizes + alternative
Images can usually be fixed for the user; other file types get a specific suggestion rather than a bare rejection.

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.

← Back to File Upload Validation