By Performate
File Upload Stress Tests: Multipart Limits, Timeouts, and Storage Bottlenecks
Stress multipart pipelines with realistic payload sizes, concurrent uploads, and antivirus latency simulation.
Your API returns 200 for a 2 MB avatar in Postman, then production melts when marketing runs a campaign with 15 MB PDF receipts. Multipart upload paths are rarely “just another POST”: they cross reverse proxies, WAFs, virus scanners, object storage SDKs, and async workers. A multipart file upload load test must stress each boundary with realistic sizes and concurrency—not a single happy-path file from your laptop.
In this guide you will learn why upload performance is a pipeline problem, how to model varied payloads in k6, when to simulate scan latency, and which signals should block a release before users hit 413 errors or minute-long ACK delays.
Why upload paths fail under load—not under manual tests
Functional checks prove the server accepts a file. Load tests prove the system accepts many files at once:
- Reverse proxies enforce
client_max_body_sizeor equivalent—failures show as 413 with no application log line. - Application servers buffer multipart parts in memory; concurrent large uploads spike heap and GC pauses.
- Antivirus or content inspection queues work asynchronously—HTTP 200 arrives late even when storage succeeded.
- Object storage throttles PUT rates or connection counts; tail latency grows while error rates stay flat.
- CDN or edge may terminate uploads differently than origin—testing only through a regional generator misses geo-specific limits.
Think of uploads as a conveyor belt with three weigh stations. Testing only the first station tells you nothing about backups at scanning or S3.
Pair upload scenarios with k6 parameterization from CSV or JSON so median and P95 attachment sizes from analytics drive the mix—not one developer’s sample PNG.
Practical k6 implementation: multipart bodies, tags, and custom metrics
k6 sends multipart bodies with http.file() and http.multipartBody() (binary uploads). Tag each iteration by size bucket so summaries split http_req_duration and failures per payload class.
Example script (illustrative—not production-ready). Uses fictional URLs, tokens, and thresholds. Adapt paths, auth, file fixtures, and SLO numbers to your environment.
What this example demonstrates:
- Size mix: SharedArray picks small, medium, and large fixtures to mimic analytics percentiles—not one static blob.
- Multipart upload:
http.file()attaches binary data with correct content-type for boundary encoding. - Upload-specific metric: A Trend records time from request start until response headers—useful when ACK lags behind storage completion.
- Checks beyond status: Validates JSON includes an object key or upload id so “200 with empty body” does not pass silently.
- Env-driven concurrency:
UPLOAD_RPSlets CI replay the same script when gateway limits change.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
import { SharedArray } from 'k6/data';
const uploadAck = new Trend('upload_ack_ms', true);
const files = new SharedArray('fixtures', function () {
return [
{ tag: 'size:small', path: './fixtures/receipt-200kb.pdf', mime: 'application/pdf' },
{ tag: 'size:medium', path: './fixtures/invoice-2mb.pdf', mime: 'application/pdf' },
{ tag: 'size:large', path: './fixtures/bundle-12mb.zip', mime: 'application/zip' },
];
});
const BASE = __ENV.API_BASE || 'https://staging.example.com';
const TOKEN = __ENV.API_TOKEN || 'replace-me';
export const options = {
scenarios: {
uploads: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.UPLOAD_RPS || 8),
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 40,
maxVUs: 120,
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
'http_req_duration{size:large}': ['p(95)<45000'],
upload_ack_ms: ['p(99)<60000'],
},
};
export default function () {
const pick = files[Math.floor(Math.random() * files.length)];
const bin = open(pick.path, 'b');
const body = {
file: http.file(bin, pick.path.split('/').pop(), pick.mime),
purpose: 'claim_attachment',
};
const res = http.post(`${BASE}/v1/uploads`, body, {
headers: { Authorization: `Bearer ${TOKEN}` },
tags: { name: 'multipart_upload', size: pick.tag.split(':')[1] },
timeout: '120s',
});
uploadAck.add(res.timings.waiting);
check(res, {
'status is 201 or 202': (r) => r.status === 201 || r.status === 202,
'upload id present': (r) => r.json('uploadId') !== undefined,
});
sleep(1);
}
For scan-queue behavior, add a second scenario that polls status endpoints if your API returns 202 + processing. That separates “accept latency” from “ready latency”—critical when product promises “uploaded in 30 seconds.”
Decision framework: what to simulate in staging
| Situation | Recommended action |
|---|---|
| Steady product traffic, mixed attachment sizes | Constant-arrival-rate with SharedArray weighted to analytics percentiles |
| Launching larger max file size | Spike scenario with 90% large payloads for 10 minutes; watch 413 and memory |
| Async virus scan on ACK | Track upload_ack_ms and optional poll loop; alert on p99 ACK, not just HTTP errors |
| Direct-to-S3 presigned URLs | Load-test both mint-url API and client PUT if browsers upload elsewhere |
| Gateway body limit change | Binary search sizes near limit in smoke; gate CI on 413 rate = 0 |
Use a weighted size mix when production logs show long tails—median-only tests miss quadratic storage cost.
Use a large-payload spike when marketing or compliance increases allowed bytes—proxies often fail before application code.
Use presigned URL two-step flows when clients upload to object storage; hammering only your API misses CDN and storage throttles.
Observability and pre-release checklist
Multipart regressions are painful to debug without tags and fixtures under version control. Before you raise RPS:
- Document size percentiles (p50, p95, max) and which fixture files represent each bucket.
- Confirm staging proxy body limits match production—or document intentional deltas.
- Tag k6 iterations with
size:small|medium|large(or byte ranges) for per-bucket thresholds. - Archive fixture checksums and script git SHA per run so “slow upload” compares apples to apples.
- Add a CI smoke gate after gateway changes: low rate, one file per bucket, strict 413/5xx thresholds (load testing in CI/CD).
How Performate simplifies multipart load testing
Hand-building binary blobs every sprint does not scale. Below is a concrete workflow for the same /v1/uploads journey this article discusses—adapt collection names and rates to your analytics.
Example: stress receipts without maintaining three script forks
- Import a Postman collection with small, medium, and large file-upload requests (or duplicate one request and swap binary attachments in the UI). Problem solved: one workspace instead of three repos with drifting boundaries.
- Create a scenario with constant arrival rate at 8 req/s (or your staging-safe ceiling) and attach the three requests with weights 60/30/10 to mirror last month’s attachment distribution. Problem solved: honest mix without editing SharedArray code each tuning pass.
- Set tags per request:
size:small,size:medium,size:large, plusroute:uploads. Problem solved: reports align with the k6 tag model above. - Run and open the comparison view—filter by size tag and check whether
p95on large files still meets the SLO while small files carry most volume. Problem solved: product and infra debate one export, not three spreadsheets. - When the gateway raises limits, update attachments in the collection and re-run—same scenarios, new fixtures, no script fork. Problem solved: limit changes are a collection update, not a new repository.
- Export generated k6 for CI smoke gates so local tuning and pipeline runs stay aligned.
That workflow maps to the cta in this post: orchestrate multipart journeys from collections without hand-building binary blobs each sprint.
Closing takeaway
Upload performance is a pipeline problem: proxies, app memory, scanners, and storage each introduce different failure modes. Load-test the size mix and concurrency your product actually sees, tag every payload class, and treat slow ACKs with clean HTTP status as a release risk—not a monitoring afterthought.
Run this week’s analytics-weighted mix against staging before your next gateway or max-size change—and note which size bucket owns the latency tail your SLO cares about.
Ready to optimize your API performance?
Use Performate to orchestrate multipart journeys from collections without hand-building binary blobs each sprint.