---
title: "File Upload Stress Tests: Multipart Limits, Timeouts, and Storage Bottlenecks"
description: "Stress multipart pipelines with realistic payload sizes, concurrent uploads, and antivirus latency simulation."
publishDate: "2026-07-02"
draft: false
keyword: "multipart file upload load test"
intent: "MOFU"
cta: "Use Performate to orchestrate multipart journeys from collections without hand-building binary blobs each sprint."
tags: ["k6","load-testing","api-performance","multipart","file","upload"]
---

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_size` or 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](/en/blog/k6-parameterization-csv-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](https://k6.io/docs/examples/data-uploads-with-binary-files/)). 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_RPS` lets CI replay the same script when gateway limits change.

```javascript
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](/en/blog/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**

1. **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.
2. **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.
3. **Set tags per request:** `size:small`, `size:medium`, `size:large`, plus `route:uploads`. *Problem solved:* reports align with the k6 tag model above.
4. **Run and open the comparison view**—filter by size tag and check whether `p95` on large files still meets the SLO while small files carry most volume. *Problem solved:* product and infra debate one export, not three spreadsheets.
5. **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.
6. **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.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 HTTP requests](https://k6.io/docs/using-k6/http-requests/)
