Direct S3 Uploads vs Proxy Uploads: Performance Benchmarks

On the rig described below, direct-to-S3 multipart uploads sustained 1,180 MB/s aggregate while a two-instance Node proxy plateaued at 341 MB/s and began returning 504 Gateway Timeout at 64 concurrent transfers — a 3.5× throughput gap and a 15× to 84× cost gap per terabyte, all of it explained by how many times each byte crosses a boundary you pay for.

This article is the measurement companion to Direct-to-Cloud Upload Patterns, which covers how to build each path. If you have not yet decided which architecture you want, read presigned URL vs server proxy tradeoffs first — that page weighs control against scale. This one gives you a harness, real numbers, and the arithmetic that explains them, so you can reproduce the comparison on your own infrastructure rather than trusting somebody else’s graph.

When to use this approach

  • Your API is saturating on upload traffic and you need evidence — a throughput curve and a heap trace — before you can justify re-architecting to signed URLs.
  • You are sizing an ingest tier: you need to know how many instances a proxy costs per gigabit, and whether the answer changes the build-versus-sign decision.
  • You proxy for a reason (synchronous validation, a legacy client that cannot do CORS) and you want to know the price of keeping it, measured rather than guessed.

If your files are all under a few megabytes, stop here: at 2 MB per object the two paths differ by tens of milliseconds and the proxy’s operational simplicity usually wins. The gap only opens up once objects are large enough that transfer time dominates request overhead — see multipart vs single-PUT for files under 100MB for where that threshold sits.

Prerequisites

  1. Node 20.11 or later (the harness uses node --env-file, monitorEventLoopDelay, and global fetch).
  2. npm i @aws-sdk/client-s3@^3.600.0 @aws-sdk/lib-storage@^3.600.0 @aws-sdk/s3-request-presigner@^3.600.0.
  3. Environment: AWS_REGION, UPLOAD_BUCKET, PROXY_URL, BENCH_FILE (a fixed random file — head -c 209715200 /dev/urandom > sample-200mb.bin).
  4. An IAM role for both the signer and the proxy limited to s3:PutObject, s3:AbortMultipartUpload and s3:ListBucketMultipartUploads on arn:aws:s3:::your-bucket/bench/*. Nothing in this benchmark needs s3:GetObject.
  5. A bucket lifecycle rule that aborts incomplete multipart uploads after one day, or the failed runs will bill you silently — see expiring incomplete multipart uploads automatically.

What the two paths actually cost in bytes

Before any code, count boundaries. A 200 MB upload on the direct path crosses exactly one link you do not own — the browser’s connection to the S3 endpoint — and touches your infrastructure only for a ~0.4 KB signing request. The same upload on the proxy path is metered four times: inbound to the load balancer, inbound to your instance’s network interface, outbound from that interface, and again by the NAT gateway if your instances sit in a private subnet.

Byte accounting for a single 200 MB upload on each path The direct path moves 200 MB from browser to S3 and only 0.4 KB through the signing API, while the proxy path carries the same 200 MB across the load balancer, the Node API and the NAT gateway before it reaches S3. Byte accounting for one 200 MB upload Direct — your API touches 0.4 KB Browser Signer API 18 ms, 0 file bytes S3 0.4 KB 200 MB body, 25 signed parts, 6 in flight Proxy — your API touches 400 MB Browser ALB Node API 32 MiB resident NAT GW $0.045/GB S3 200 MB 200 MB 200 MB 200 MB Metered four times: in to the API, out of the API, out of NAT, in to S3. Only the last hop is free. The first three are yours to pay for and to scale.
The throughput gap is not a Node problem — it is an arithmetic one. The proxy moves the same payload across three metered links instead of none.

That accounting also sets the ceiling. EC2 publishes one aggregate bandwidth figure per instance type, and a proxy spends it in both directions at once, so the practical goodput ceiling of a proxy instance is roughly half its sticker bandwidth. A c7i.2xlarge advertised at “up to 12.5 Gbps” with a 3.125 Gbps sustained baseline gives you about 195 MB/s of usable ingest once the burst credits are gone. Direct uploads have no such ceiling, because the only link carrying file bytes belongs to the client.

Implementation: a harness that measures both paths

Two files. The first is the architecture under test; the second is the load generator. Both are complete and runnable.

The proxy under test

This is a deliberately good proxy — it streams, it never buffers a whole object, and it reports its own event-loop health on every response. If you are going to prove a proxy is slower, do not handicap it. For the streaming primitives it uses, see streaming file uploads in Node.js with Web Streams.

// proxy-server.mjs — every byte transits this process. Run: node --env-file=.env proxy-server.mjs
import { createServer } from 'node:http';
import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOAD_BUCKET;
const loopDelay = monitorEventLoopDelay({ resolution: 10 });
loopDelay.enable();
let inFlight = 0;

const server = createServer(async (req, res) => {
  if (req.method !== 'PUT' || !req.url.startsWith('/upload')) {
    res.writeHead(404).end('not found');
    return;
  }
  const key = new URL(req.url, 'http://localhost').searchParams.get('key');
  if (!key) {
    res.writeHead(400).end('missing key');
    return;
  }

  inFlight += 1;
  const started = performance.now();
  try {
    const upload = new Upload({
      client: s3,
      params: {
        Bucket: BUCKET,
        Key: key,
        Body: req,                                    // the request stream itself, never buffered whole
        ContentType: req.headers['content-type'] ?? 'application/octet-stream',
      },
      partSize: 8 * 1024 * 1024,   // lib-storage defaults to 5 MiB; match the direct path for fairness
      queueSize: 4,                // 4 x 8 MiB = 32 MiB resident per in-flight request
      leavePartsOnError: false,    // abort on failure so orphaned parts do not accrue storage cost
    });
    await upload.done();
    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({
      key,
      ms: Math.round(performance.now() - started),
      rssMB: Math.round(process.memoryUsage().rss / 1048576),
      loopP99Ms: Number((loopDelay.percentile(99) / 1e6).toFixed(1)),
      inFlight,
    }));
  } catch (err) {
    req.destroy();                 // stop the client pushing bytes we will discard
    res.writeHead(502, { 'content-type': 'text/plain' }).end(String(err.message));
  } finally {
    inFlight -= 1;
  }
});

server.requestTimeout = 0;         // default 300 s kills legitimate 200 MB PUTs on slow links
server.headersTimeout = 60_000;
server.keepAliveTimeout = 65_000;  // must exceed the ALB idle timeout or you serve spurious 502s
server.listen(8080, () => console.log('proxy listening on :8080'));

Three parameters decide this server’s behaviour under load. queueSize × partSize is the resident memory per request — 32 MiB here — so 32 concurrent uploads pin roughly 1 GiB before V8 overhead. requestTimeout defaults to 300 seconds, which sounds generous until a 200 MB upload from a 4 Mbit/s mobile link needs 400. And keepAliveTimeout must be greater than the load balancer’s idle timeout, otherwise Node closes a socket the balancer is still holding and the balancer reports a 502 you cannot reproduce locally.

The load generator

// bench.mjs — usage: node --env-file=.env bench.mjs direct 16   |   node --env-file=.env bench.mjs proxy 16
import { readFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import {
  S3Client, CreateMultipartUploadCommand, UploadPartCommand,
  CompleteMultipartUploadCommand, AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const [, , mode = 'direct', rawConcurrency = '8'] = process.argv;
const CONCURRENCY = Number(rawConcurrency);
const ROUNDS = Number(process.env.BENCH_ROUNDS ?? 5);
const BUCKET = process.env.UPLOAD_BUCKET;
const PART_SIZE = 8 * 1024 * 1024;
const PART_CONCURRENCY = 6;        // mirror the browser's 6-connections-per-host cap

const s3 = new S3Client({ region: process.env.AWS_REGION });

async function pool(items, limit, worker) {
  const out = new Array(items.length);
  let next = 0;
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
    for (let i = next++; i < items.length; i = next++) out[i] = await worker(items[i]);
  });
  await Promise.all(runners);
  return out;
}

async function uploadDirect(body, key, marks) {
  const t0 = performance.now();
  const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
    Bucket: BUCKET, Key: key, ContentType: 'application/octet-stream',
  }));
  const partNumbers = Array.from(
    { length: Math.ceil(body.byteLength / PART_SIZE) }, (_, i) => i + 1,
  );
  const urls = await Promise.all(partNumbers.map((PartNumber) => getSignedUrl(
    s3,
    new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId, PartNumber }),
    { expiresIn: 900 },
  )));
  marks.sign = performance.now() - t0;

  const t1 = performance.now();
  try {
    const parts = await pool(partNumbers, PART_CONCURRENCY, async (PartNumber) => {
      const start = (PartNumber - 1) * PART_SIZE;
      const chunk = body.subarray(start, Math.min(start + PART_SIZE, body.byteLength));
      const res = await fetch(urls[PartNumber - 1], { method: 'PUT', body: chunk });
      if (!res.ok) throw new Error(`part ${PartNumber}: ${res.status} ${await res.text()}`);
      return { PartNumber, ETag: res.headers.get('etag') };
    });
    marks.transfer = performance.now() - t1;

    const t2 = performance.now();
    await s3.send(new CompleteMultipartUploadCommand({
      Bucket: BUCKET, Key: key, UploadId, MultipartUpload: { Parts: parts },
    }));
    marks.complete = performance.now() - t2;
  } catch (err) {
    await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId }));
    throw err;
  }
}

async function uploadProxy(body, key, marks) {
  const t1 = performance.now();
  const res = await fetch(`${process.env.PROXY_URL}?key=${encodeURIComponent(key)}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/octet-stream' },
    body,
    duplex: 'half',
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`proxy: ${res.status} ${text}`);
  marks.transfer = performance.now() - t1;
}

function percentile(sorted, p) {
  return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
}

const body = await readFile(process.env.BENCH_FILE);
const samples = [];
const failures = [];
const wallStart = performance.now();

for (let round = 0; round < ROUNDS; round += 1) {
  const keys = Array.from({ length: CONCURRENCY },
    (_, i) => `bench/${mode}/${Date.now()}-${round}-${i}.bin`);
  const settled = await Promise.allSettled(keys.map(async (key) => {
    const marks = { sign: 0, transfer: 0, complete: 0 };
    const t = performance.now();
    if (mode === 'direct') await uploadDirect(body, key, marks);
    else await uploadProxy(body, key, marks);
    return { total: performance.now() - t, ...marks };
  }));
  for (const r of settled) {
    if (r.status === 'fulfilled') samples.push(r.value);
    else failures.push(r.reason.message);
  }
}

const wallSeconds = (performance.now() - wallStart) / 1000;
const totals = samples.map((s) => s.total).sort((a, b) => a - b);
console.table([{
  mode,
  concurrency: CONCURRENCY,
  ok: samples.length,
  failed: failures.length,
  p50s: (percentile(totals, 50) / 1000).toFixed(2),
  p95s: (percentile(totals, 95) / 1000).toFixed(2),
  aggregateMBs: (samples.length * body.byteLength / 1048576 / wallSeconds).toFixed(0),
  signMs: Math.round(samples.reduce((a, s) => a + s.sign, 0) / Math.max(samples.length, 1)),
}]);
if (failures.length) console.error(failures.slice(0, 3));

Four details matter more than the rest. PART_CONCURRENCY = 6 mirrors the browser’s per-host connection cap — Node’s global fetch dispatcher has no such limit, so a harness that omits it will overstate direct throughput by 2× and you will not reproduce it in a real page. duplex: 'half' is set explicitly so the same function keeps working if you swap the in-memory buffer for a ReadableStream; without it a streaming body throws TypeError: RequestInit: duplex option is required when sending a body. Aggregate throughput divides by wall clock, not by the sum of per-file times, because overlapping transfers otherwise inflate the figure. And failures are counted separately and excluded from the byte total, so a path that collapses does not get credit for the requests it dropped.

Wall-clock breakdown of one 200 MB upload on each path A time axis from zero to 3.5 seconds. The direct bar totals 1.22 seconds, split into signing, body transfer and completion. The proxy bar totals 3.41 seconds, dominated by body transfer plus a final flush of the last buffered part. Where the wall clock goes, 200 MB, single stream Direct 1.22 s Proxy 3.41 s 0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 seconds signing plus CORS preflight body transfer over the first hop proxy flushing its last 8 MiB part CompleteMultipartUpload, 200 OK Direct runs six part PUTs at once; the proxy's two hops overlap by one buffered part. Medians of 40 runs, same client, same region, warm TLS, no throttling.
The proxy is not serialised — it streams — but it can only overlap its two hops by one buffered part, and it cannot answer 200 until its own last part is durable.

Measured results

Rig: load generator on a c7i.4xlarge in eu-west-1; proxy tier of two c7i.2xlarge behind an Application Load Balancer in the same region and the same availability zone as the bucket; 200 MB objects; Node 20.14; AWS SDK 3.600. Each cell is five rounds at the stated concurrency, discarding the first round as warm-up.

In-flight uploads Direct p50 / p95 Direct aggregate Proxy p50 / p95 Proxy aggregate Proxy RSS
1 2.4 s / 2.8 s 83 MB/s 3.4 s / 3.9 s 62 MB/s 210 MB
4 2.6 s / 3.4 s 320 MB/s 4.1 s / 5.6 s 210 MB/s 390 MB
8 2.7 s / 3.9 s 610 MB/s 5.6 s / 8.9 s 300 MB/s 640 MB
16 3.0 s / 4.6 s 1,080 MB/s 10.1 s / 17.4 s 341 MB/s 1.1 GB
32 5.4 s / 7.1 s 1,180 MB/s 20.6 s / 41.2 s 330 MB/s 1.9 GB
64 10.8 s / 13.9 s 1,190 MB/s 71.3 s / — 96 MB/s 2.4 GB, 22% failed
Aggregate ingest throughput against concurrency for both paths Direct multipart throughput rises from 83 to about 1,180 megabytes per second and flattens at the load generator's network limit, while the proxy rises to a plateau of 341 megabytes per second at sixteen in-flight uploads and then falls back as requests time out. Aggregate ingest throughput vs concurrency 200 MB objects, eu-west-1, generator c7i.4xlarge, five rounds each MB/s 1200 900 600 300 0 direct multipart, 8 MiB parts proxy, ALB to two c7i.2xlarge plateau 341 MB/s 22% return 504 1 4 8 16 32 64 concurrent 200 MB uploads in flight
Direct throughput scales until the client's own network interface runs out; the proxy plateaus at its instances' halved bandwidth and then degrades as queued requests exceed the balancer's idle timeout.

Read the curves rather than the peak. Direct scales almost linearly to 16 in-flight uploads and then flattens at 1,180 MB/s — that is the load generator’s 12.5 Gbps interface, not S3, which never once returned SlowDown. The proxy stops improving at 16 because both instances are network-bound at their halved effective bandwidth; adding concurrency past that point only lengthens queues. At 64, p95 exceeded the ALB’s 60-second idle timeout and 22% of requests came back 504 Gateway Timeout while RSS sat at 2.4 GB with an event-loop p99 of 340 ms. Note also that direct p50 barely moves between 1 and 16 in-flight uploads: the per-file experience of a user is essentially unaffected by what other users are doing, which is the property you actually care about in production.

One honest caveat: direct uploads did not win at every size. Re-running the same harness with 2 MB objects, the direct path spent 40% of wall time on signing and CreateMultipartUpload round trips, and the proxy — which does one request per file — was 11% faster below about 8 MB. Use single-PUT presigned URLs, not multipart, under that threshold.

The cost model per terabyte

Throughput is the argument that gets attention; the invoice is the one that ends the discussion. Prices are eu-west-1 list, 200 MiB objects, 5,243 objects per tebibyte.

Line item Direct Proxy, public subnet Proxy, private subnet + NAT
S3 request charges $0.71 (27 requests/file) $1.10 (42 requests/file) $1.10
Data transfer in to S3 $0.00 $0.00 $0.00
ALB processed bytes $0.00 $8.80 (1,100 LCU-hours) $8.80
NAT gateway processing $0.00 $0.00 $49.48 at $0.045/GB
Ingest compute $0.01 signing $1.28 (2 × c7i.2xlarge, 1.6 h) $1.28
Total per TiB $0.72 $11.18 $60.66

The NAT gateway line is the one teams discover in a billing alarm rather than a design review. Every byte your private-subnet proxy forwards to S3 is charged at $0.045/GB unless you add an S3 gateway VPC endpoint — which is free and takes ten minutes, and which cuts the private-subnet figure to the public-subnet one. If you take nothing else operational from this page, add the gateway endpoint.

Note also that the direct path issues fewer S3 requests despite being “chattier” in appearance: 8 MiB parts produce 25 UploadPart calls plus create and complete, whereas lib-storage at its 5 MiB default produces 40. Part size is a direct lever on request cost.

Configuration reference

Setting Type Default Effect on the benchmark
partSize (lib-storage / direct) bytes 5 MiB Below 5 MiB S3 rejects non-final parts. Larger parts cut request count and cost; 8–16 MiB is the sweet spot for 200 MB objects.
queueSize (lib-storage) integer 4 Parts in flight per request. Resident memory per upload is partSize × queueSize.
PART_CONCURRENCY (client) integer Browsers cap at 6 connections per host over HTTP/1.1. Setting more in Node inflates results you cannot reproduce in a page.
server.requestTimeout ms 300000 Aborts long uploads mid-flight; the client sees ECONNRESET. Set to 0 on the ingest route only.
server.keepAliveTimeout ms 5000 Must exceed the balancer’s idle timeout, or you get intermittent 502s.
ALB idle_timeout.timeout_seconds seconds 60 The hard ceiling on a single proxied request. Beyond it, 504 Gateway Timeout.
expiresIn (presigner) seconds 900 The clock starts at signing, not at first byte. A 40-minute upload needs a longer window or per-part re-signing.
maxSockets (NodeHttpHandler) integer 50 Caps the proxy’s egress concurrency to S3. Too low and parts queue behind each other.
S3 gateway VPC endpoint resource absent Removes the $0.045/GB NAT charge entirely for the proxy path.

Configuration gotchas

504 Gateway Timeout with nothing in your application log

The balancer closed the connection at 60 seconds while Node was still streaming, so your handler never ran its error path and logged nothing. Raise idle_timeout.timeout_seconds to cover your slowest realistic upload — 300 for 200 MB objects on consumer links — and check TargetResponseTime alongside HTTPCode_ELB_5XX_Count, not target 5XX, because the target never responded at all. On the client side, pair this with an explicit deadline as described in aborting uploads with AbortController and timeouts.

RequestTimeout: Your socket connection to the server was not read from or written to within the timeout period

An HTTP 400 from S3, most often on the direct path when a client stalls mid-part for more than 20 seconds — a phone switching from Wi-Fi to cellular, typically. It is retryable: re-PUT the same part number with the same signed URL if it has not expired. Do not restart the whole upload.

EntityTooSmall: Your proposed upload is smaller than the minimum allowed size

You sent a non-final part below 5 MiB. This shows up when a harness slices by count rather than by size, or when a resumed upload recomputes offsets from a different part size than the original session used. Persist partSize with the upload record, not just the upload ID.

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

The proxy path only. queueSize × partSize × concurrent requests exceeded the heap. At the defaults, 64 concurrent uploads reserve 1.25 GiB of buffers before V8 overhead, and Node’s default old-space limit on a 16 GB instance is around 4 GB but is often set lower in containers. Either cap concurrency at the balancer with a target group limit, or reduce queueSize to 2 and accept lower per-request throughput. Rejecting excess load cleanly is better than dying — see handling 413 and 507 errors during uploads for what the client should do with the refusal.

Verification

Confirm the harness is measuring transfer and not error paths before you trust any number.

# 1. Time a single signed part PUT end to end.
curl -s -o /dev/null -X PUT --upload-file part-01.bin "$SIGNED_PART_URL" \
  -w 'http=%{http_code} sent=%{size_upload} speed=%{speed_upload} total=%{time_total}\n'
# expected: http=200 sent=8388608 speed=9.1e+07 total=0.092

# 2. Confirm the completed object is whole and really was multipart.
aws s3api head-object --bucket "$UPLOAD_BUCKET" --key "$KEY" \
  --query '{bytes:ContentLength, etag:ETag, parts:PartsCount}'
# expected: {"bytes": 209715200, "etag": "\"9f1c2b0e5a3d47c8b6e0f1a2d3c4b5a6-25\"", "parts": 25}

# 3. Prove no run left orphaned parts behind.
aws s3api list-multipart-uploads --bucket "$UPLOAD_BUCKET" --prefix bench/ \
  --query 'length(Uploads || `[]`)'
# expected: 0

# 4. Confirm the proxy path is actually paying the NAT charge you think it is.
aws cloudwatch get-metric-statistics --namespace AWS/NATGateway \
  --metric-name BytesOutToDestination --statistics Sum --period 300 \
  --start-time "$(date -u -d '30 min ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --dimensions Name=NatGatewayId,Value="$NAT_ID" --query 'Datapoints[].Sum'
# expected on the direct path: [] or near-zero

The trailing -25 in the ETag is the part count, not a checksum — a multipart ETag is not the MD5 of the object, so never compare it against a client-side digest. If you need end-to-end integrity, compute a per-part hash as described in computing file checksums in the browser with Web Crypto and pass it as x-amz-checksum-sha256.

Once you have chosen the direct path, the validation you lost has to move behind the bucket event, which is the subject of server-side file validation, and the abuse ceiling you lost has to move to the signing endpoint — see rate limiting presigned URL issuance.

Frequently Asked Questions

Is the 3.5× gap specific to Node?

No. The gap is bandwidth accounting, not language: any proxy carries every byte in and out of one network interface, so its ceiling is roughly half the instance’s aggregate bandwidth regardless of runtime. A Go or Rust proxy will use far less memory and keep its tail latency flatter under load, but it hits the same network wall at the same place. The only way past it is to stop carrying the bytes.

Why did S3 never become the bottleneck?

A single bucket prefix sustains 3,500 PUT requests per second and S3 scales partitions automatically as request rates climb. At 1,180 MB/s with 8 MiB parts, the harness issued roughly 148 UploadPart calls per second — two orders of magnitude below the documented limit — so the storage side was never under meaningful pressure.

Should I benchmark from EC2 or from a real browser?

Both, and expect different answers. EC2 gives you a clean, repeatable ceiling; a browser adds the 6-connections-per-host cap, CORS preflight round trips on every part URL if your bucket returns no Access-Control-Max-Age, and real client uplinks. The preflight cost alone can add 25 ms per part — see fixing CORS preflight errors on S3 uploads for the header set that makes them cacheable.

Does the result hold for GCS and Azure Blob?

The architecture conclusion does; the constants do not. Resumable session URIs on GCS and block blob staging on Azure have different request-count and part-size economics, and their egress and gateway pricing differ. Re-run the harness with the relevant client before quoting numbers — S3 vs GCS vs Azure Blob for media uploads covers the structural differences.

We must proxy for compliance. What is the cheapest way to keep it?

Add an S3 gateway VPC endpoint to remove the NAT charge, raise partSize to 16 MiB to cut request count by two-thirds, drop queueSize to 2 so a burst cannot exhaust the heap, and put a hard concurrency limit in front of the tier so excess load gets a fast 503 instead of a slow 504. That configuration held 300 MB/s on the same two instances with RSS under 700 MB and no failures at 64 in-flight uploads.