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

Steps

  1. 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')=null

    size is exact and in bytes. HTML has no attribute that limits it: maxlength on a file input is ignored, and there is no max. Any client-side limit is code you wrote.

  2. 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."

    setCustomValidity is the only route from a size to a validity flag. It also runs in the browser, so the rest of this page ignores it.

  3. 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 bytes

    The 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.

  4. 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)"
    done
    
    1048367 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 -> 413

    Identical 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.

  5. 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. requestBytes shows 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.

  6. 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

209 bytes of multipart envelope for curl 8.21.0 with a 5-character file name, 193 bytes for Chrome 152 with the same file. The envelope grows byte for byte with the file name. Source: Measured against the local endpoint on 2026-09-11, see the Verified block
A limit of 1048576 bytes of request body accepted a file of at most 1048367 bytes from curl and 1048383 bytes from Chrome. Source: Measured against the local endpoint on 2026-09-11, see the Verified block

Common mistakes

Sign: The measured ceiling is a few hundred bytes below the configured limit and it looks like an off-by-one.Cause: The limit counts the request body, and the file rides inside a multipart part with a boundary, a Content-Disposition line and a Content-Type line. Here the configured 1048576 accepted 1048367 bytes of file, 209 bytes short. Subtracting the envelope explains the whole gap, so there is nothing to debug.
Sign: A file that passed yesterday is refused today with no code change.Cause: Its name got longer. The same 1048367 bytes returned 200 as f.bin and 413 as quarterly-report-2026-final.bin, and the new ceiling was exactly 26 bytes lower for a name exactly 26 characters longer. Hold the file name constant for the whole bisection, or the search converges on a moving target.
Sign: curl and the browser report different limits for the same endpoint.Cause: Each client builds its own envelope. Chrome used 193 bytes where curl used 209, so a 1048383-byte file returned 200 from Chrome and 413 from curl on the same server in the same minute. Quote the client and the file name alongside any ceiling you publish.
Sign: A large upload is recorded as passing because the response was 200.Cause: One of the three endpoints answered 200 with truncated: true after storing only the first 1048576 bytes. The trailing boundary was cut off, which is why the parsed file length came back as -156. A size test has to assert on the stored bytes, not on the status code.

What to check next

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.

intermediate15 minpublished updated Maks Verny