How to test file upload size limit
Do not assert the number from the ticket. Post files of shrinking size with curl and bisect until one status flips. Against the endpoint below, configured at 1048576 bytes, the largest file accepted was 1048367 bytes and one byte more returned 413. The gap is the multipart envelope.
Why check this
Run this after any change to a proxy, a body-parser option or an upload widget, and once before release. Three numbers are usually assumed to agree and rarely do: the limit in the specification, the limit the JavaScript guard enforces, and the byte count the server actually refuses.
The failure it prevents is a user who loses ten minutes to an upload that dies without a message. The server can reject an oversized body in three ways, and only one of them lets the client say why. A test that submits a 10 MB file against a 1 MB limit and records "upload failed" has not distinguished a clean refusal from a broken connection or from silent corruption.
Prerequisites
- Node 22 and curl. The
form-lab.mjsserver from How to test form validation messages, started withnode form-lab.mjs 9317. It caps the request body atLIMIT = 1048576bytes and offers three endpoints that hit that cap differently:/uploadanswers 413,/upload-resetdestroys the socket,/upload-truncateanswers 200 with a partial file. - Files of an exact size, from
head -c <bytes> /dev/zero > f.bin. Keep the file name constant for the whole search. Step 4 shows why. - Nothing on this page touches a third-party endpoint. Do not bisect against somebody else's service.
- The browser figures are one capture, Chrome 152.0.7977.76, on 2026-09-11.
Steps
- Step 1.
Read what the page knows about the file before it sends anything.
const el = document.getElementById('doc'), f = el.files[0]; el.setAttribute('maxlength', '100'); `files[0].size=${f.size} bytes (${(f.size / 1048576).toFixed(3)} MiB) maxlength="100" on the file input -> tooLong=${el.validity.tooLong} valid=${el.validity.valid} no size attribute exists: el.getAttribute('max')=${el.getAttribute('max')}`;files[0].size=1048367 bytes (1.000 MiB) maxlength="100" on the file input -> tooLong=false valid=true no size attribute exists: el.getAttribute('max')=nullsizeis exact and in bytes. HTML has no attribute that limits it:maxlengthon a file input is ignored, and there is nomax. Any client-side limit is code you wrote. - Step 2.
Add the guard the product needs, and confirm it reaches constraint validation.
const el = document.getElementById('doc'), MAX = 500000, f = el.files[0]; el.setCustomValidity(f.size > MAX ? `File is ${f.size} bytes, the limit is ${MAX}.` : ''); `valid=${el.validity.valid} message="${el.validationMessage}"`;at 1048367 bytes: valid=true message="" with the limit at 500000: valid=false message="File is 1048367 bytes, the limit is 500000."setCustomValidityis the only route from a size to a validity flag. It also runs in the browser, so the rest of this page ignores it. - Step 3.
Bisect the real ceiling against the endpoint. Nineteen requests find one byte.
lo=0; hi=2097152 while [ $((hi - lo)) -gt 1 ]; do mid=$(((lo + hi) / 2)); head -c $mid /dev/zero > f.bin code=$(curl -s -o /dev/null -w '%{http_code}' -F file=@f.bin http://localhost:9317/upload) echo "$mid bytes -> $code" if [ "$code" = "200" ]; then lo=$mid; else hi=$mid; fi done; echo "largest accepted: $lo bytes; smallest rejected: $hi bytes"1048576 bytes -> 413 524288 bytes -> 200 … 1048320 bytes -> 200 1048448 bytes -> 413 1048384 bytes -> 413 1048352 bytes -> 200 1048368 bytes -> 413 1048360 bytes -> 200 1048364 bytes -> 200 1048366 bytes -> 200 1048367 bytes -> 200 largest accepted: 1048367 bytes; smallest rejected: 1048368 bytesThe configured limit is 1048576. The measured ceiling is 209 bytes lower, because the limit counts the whole request body and the file travels inside a multipart envelope.
- Step 4.
Copy the same bytes under a longer name and send it again.
cp f.bin quarterly-report-2026-final.bin for f in f.bin quarterly-report-2026-final.bin; do echo "$(stat -c%s "$f") bytes as $f -> $(curl -s -o /dev/null -w '%{http_code}' -F file=@"$f" http://localhost:9317/upload)" done1048367 bytes as f.bin -> 200 1048367 bytes as quarterly-report-2026-final.bin -> 413 1048341 bytes as quarterly-report-2026-final.bin -> 200 1048342 bytes as quarterly-report-2026-final.bin -> 413Identical contents, opposite verdicts. The last two rows repeat the bisection under the long name: the ceiling moved to 1048341, exactly 26 bytes lower, and the name grew by exactly 26 characters. The file name travels in the request body and is counted with it.
- Step 5.
Attach a file of 1048383 bytes, sixteen bytes above the ceiling curl found, and submit it from the browser. Repeat with one byte more.
document.getElementById('docs').querySelector('button').click();chrome, 1048383 bytes -> {"stored":true,"filename":"f.bin","fileBytes":1048383,"requestBytes":1048576,"truncated":false} chrome, 1048384 bytes -> {"error":"file too large","limit":1048576,"received":1048577}curl returned 413 for that same 1048383-byte file, as step 3 predicts.
requestBytesshows why: Chrome's envelope is 193 bytes where curl's is 209, so Chrome's ceiling sits 16 bytes higher on the same server. A limit measured with one client is a figure for that client. - Step 6.
Go past the limit on each of the three endpoints and record what the client receives.
head -c 1200000 /dev/zero > f.bin for p in upload upload-reset upload-truncate; do curl -s -S -w '\nhttp=%{http_code} uploaded=%{size_upload} exit=%{exitcode} err=%{errormsg}\n' -F file=@f.bin http://localhost:9317/$p; done{"error":"file too large","limit":1048576,"received":1114112} http=413 uploaded=1200209 exit=0 err= curl: (56) Recv failure: Connection was reset http=100 uploaded=1200209 exit=56 err=Recv failure: Connection was reset {"stored":true,"filename":"f.bin","fileBytes":-156,"requestBytes":1200209,"truncated":true} http=200 uploaded=1200209 exit=0 err=Three behaviours from one oversized file. Read them in the table below.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 413 with a body naming the limit | The server refused and said why. The only decent outcome | Confirm the UI shows that message instead of a generic failure. |
| exit=56, http=100, no response body | The socket was destroyed while the client was still sending. The 100 is the interim Continue line, the only status curl ever saw | File a defect. The client cannot tell this apart from a network drop and will usually retry. |
| 200 with truncated: true | The server stored a partial file and reported success | The worst of the three. Nothing downstream knows the file is incomplete. |
| uploaded equals the full file size on every row | The client sent everything before learning the answer | Expected with Expect: 100-continue. Size is refused after the bytes arrive, not before. |
Thresholds
Common mistakes
What to check next
- How to check which file types a file input accepts: the other upload rule the browser will not enforce.
- How to test form validation messages: where
setCustomValidityfrom step 2 fits, and its sticky behaviour. - How to test API error responses: what the 413 body owes the caller.
- How to check HTTP status code: reading the status when the body is empty, as in the reset case.
- How to test API timeout handling: a large upload that stalls looks like the reset case and is not.
FAQ
How do I check file input size with JavaScript?
Read input.files[0].size, which is bytes. To turn it into a validation error call setCustomValidity() with your own text, as in step 2. There is no HTML attribute for it: maxlength on a file input returned tooLong=false.
How do I test a large file upload without a large file?
Generate one. head -c 1200000 /dev/zero > f.bin makes an exact size in a moment and it compresses to nothing on disk. Keep the name short and constant.
What file upload test cases belong in a regression suite?
One file just under the ceiling, one just over, one with a disallowed type, one with a name long enough to move the ceiling, and one that is zero bytes. Assert on the status, the body, and the bytes stored.
Should the size be checked in the browser as well?
Yes, to save the user the wait. It is never the enforcement point. Every request on this page was built without a form.
Why does the client send the whole file before being refused?
curl sends Expect: 100-continue and the server answers 100 Continue before reading the body, so the refusal comes after the bytes. uploaded=1200209 on all three rows in step 6 shows it.
Verified
Verified by Maks Vernycurl 8.21.0Chrome 152.0.7977.76Node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate15 minpublished updated Maks Verny