惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX - 技术
D
DataBreaches.Net
阮一峰的网络日志
阮一峰的网络日志
Recent Announcements
Recent Announcements
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
K
Kaspersky official blog
F
Fortinet All Blogs
GbyAI
GbyAI
Forbes - Security
Forbes - Security
The Cloudflare Blog
博客园 - 司徒正美
博客园_首页
量子位
Schneier on Security
Schneier on Security
G
GRAHAM CLULEY
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Proofpoint News Feed
N
News | PayPal Newsroom
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
T
Tor Project blog
V
Vulnerabilities – Threatpost
Y
Y Combinator Blog
Jina AI
Jina AI
Help Net Security
Help Net Security
T
Threat Research - Cisco Blogs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
Cybersecurity and Infrastructure Security Agency CISA
Project Zero
Project Zero
N
News and Events Feed by Topic
I
Intezer
B
Blog
美团技术团队
C
CERT Recently Published Vulnerability Notes
NISL@THU
NISL@THU
L
LINUX DO - 最新话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
AWS News Blog
AWS News Blog
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
雷峰网
雷峰网
有赞技术团队
有赞技术团队

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Saga Pattern vs Two-Phase Commit: Distributed Transactions Without The Lies Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck
The Practica · 2026-05-22 · via The Practical Developer

Your product team wants a feature: users can upload video files up to 5 GB. You wire up a standard multipart form handler in Express, pipe the stream to a temporary disk location, and then upload the whole thing to S3 from your server. The first ten uploads work fine. Then a user on hotel WiFi tries to upload a 2 GB file. The request runs for 14 minutes, ties up one of your event loop threads the whole time, eats 200 MB of memory for the buffer, and fails at 97% because the load balancer idle timeout kills the connection. The user retries. Your server retries the S3 upload. The bill for data transfer out of EC2 to S3 in the same region is small, but the bill for your time is not.

The mistake is architectural, not algorithmic. Your API server should never see the file bytes. The client should upload directly to S3. Your server should only generate a short-lived, scoped URL that lets the client write to a specific key, and then confirm the upload completed. For files under 100 MB, a single presigned PUT URL is enough. For anything larger, you need multipart upload with presigned URLs per part. This post is the production implementation of both, in Node.js, with the security rules that keep random callers from filling your bucket.

Why streaming through your server is wrong

When a client uploads through your API, the data path looks like this:

Client → Load Balancer → Node.js Server → S3

Every byte traverses your server. That means:

  • Memory or disk pressure. Node.js streams to disk if you use multer or busboy, or memory if you buffer. Either way, you are managing resources for someone else’s data.
  • Connection pool exhaustion. The inbound HTTP connection stays open for the entire upload duration. A few slow clients can max out your connection limit.
  • Timeout fragility. Load balancers, reverse proxies, and Node.js itself all have timeouts. The longer the upload, the more likely one of them fires.
  • Double billing. EC2 data transfer to S3 in the same region is free, but if your server is in one AZ and S3 resolves to another, you pay. More importantly, you pay for the EC2 CPU, memory, and connections that do nothing but shuffle bytes.

The fix is to hand the client a temporary write credential to S3 and get out of the way:

Client → Your API  (GET presigned URL)
Client → S3        (PUT bytes directly)
Client → Your API  (POST confirm completion)

Your server sees three small JSON requests instead of a 2 GB payload.

Part 1: presigned PUT for files under 100 MB

For small files, a single presigned PUT URL is the entire solution. The client requests a URL from your API, PUTs the file directly to S3, and tells you the key.

Server: generating the presigned URL

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

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

export async function createPresignedPutUrl(
  key: string,
  contentType: string,
  maxBytes = 100 * 1024 * 1024,
) {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    ContentType: contentType,
    ContentLength: maxBytes, // S3 will reject if the body exceeds this
  });

  // URL expires in 5 minutes. The upload must start and finish inside this window.
  const url = await getSignedUrl(s3, command, { expiresIn: 300 });
  return url;
}

A few details that matter:

  • ContentType is enforced. If the client PUTs with a different Content-Type header, S3 rejects it. This stops someone from using an image upload endpoint to store executable files.
  • The key should not be client-controlled. The client should request an upload for “my-video.mp4” and your API should generate the final key, for example uploads/2026/05/user-123/${uuid}.mp4. Never let the client pick the S3 key. If they can write to ../../../index.html, you have a path traversal bug in object storage.
  • The expiration should be short. Five minutes is enough for a direct browser upload on a fast connection. If the client needs longer, they can request a fresh URL.

Client: uploading with the presigned URL

async function uploadFile(file: File): Promise<string> {
  const res = await fetch('/api/uploads/small', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  });
  const { url, key } = await res.json();

  const put = await fetch(url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  });
  if (!put.ok) throw new Error(`S3 upload failed: ${put.status}`);

  // Tell your backend the upload is done
  await fetch('/api/uploads/confirm', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key }),
  });

  return key;
}

This works for files up to the limit of a single PUT request. S3 supports up to 5 GB per PUT, but most browsers and HTTP clients start getting flaky above a few hundred megabytes. If your use case involves files larger than 100 MB, use multipart upload.

Part 2: multipart upload for large files

Multipart upload splits the file into chunks (parts), uploads each part independently, and then tells S3 to assemble them. The client uploads each part directly to S3 using its own presigned URL. Your server only tracks the upload ID and the list of completed part ETags.

Server: initiating the multipart upload

import {
  S3Client,
  CreateMultipartUploadCommand,
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  UploadPartCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

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

export async function initiateMultipartUpload(
  key: string,
  contentType: string,
) {
  const command = new CreateMultipartUploadCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    ContentType: contentType,
  });
  const response = await s3.send(command);
  return response.UploadId!;
}

Server: generating presigned URLs for each part

export async function createPresignedPartUrls(
  key: string,
  uploadId: string,
  partCount: number,
) {
  const urls: string[] = [];
  for (let partNumber = 1; partNumber <= partCount; partNumber++) {
    const command = new UploadPartCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      UploadId: uploadId,
      PartNumber: partNumber,
    });
    const url = await getSignedUrl(s3, command, { expiresIn: 600 });
    urls.push(url);
  }
  return urls;
}

Each URL is scoped to a single part number. The client cannot use the URL for part 3 to upload part 7. S3 validates the part number and upload ID.

Server: completing the multipart upload

export async function completeMultipartUpload(
  key: string,
  uploadId: string,
  parts: { PartNumber: number; ETag: string }[],
) {
  const command = new CompleteMultipartUploadCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    UploadId: uploadId,
    MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
  });
  await s3.send(command);
}

Client: streaming upload with retry per part

const PART_SIZE = 10 * 1024 * 1024; // 10 MB per part

async function uploadLargeFile(file: File) {
  // 1. Ask the server to start a multipart upload
  const initRes = await fetch('/api/uploads/multipart/init', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  });
  const { key, uploadId, partCount } = await initRes.json();

  // 2. Ask for presigned URLs for every part
  const urlsRes = await fetch('/api/uploads/multipart/urls', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key, uploadId, partCount }),
  });
  const { urls } = await urlsRes.json();

  // 3. Upload each part directly to S3
  const parts: { PartNumber: number; ETag: string }[] = [];
  for (let i = 0; i < partCount; i++) {
    const start = i * PART_SIZE;
    const end = Math.min(start + PART_SIZE, file.size);
    const chunk = file.slice(start, end);

    const etag = await uploadPartWithRetry(urls[i], chunk, i + 1);
    parts.push({ PartNumber: i + 1, ETag: etag });
  }

  // 4. Tell the server to assemble the parts
  await fetch('/api/uploads/multipart/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key, uploadId, parts }),
  });

  return key;
}

async function uploadPartWithRetry(url: string, chunk: Blob, partNumber: number, maxAttempts = 3): Promise<string> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = await fetch(url, { method: 'PUT', body: chunk });
    if (res.ok) {
      const etag = res.headers.get('ETag');
      if (!etag) throw new Error(`Missing ETag for part ${partNumber}`);
      return etag;
    }
    if (attempt === maxAttempts) throw new Error(`Part ${partNumber} failed after ${maxAttempts} attempts`);
    await new Promise(r => setTimeout(r, 1000 * attempt));
  }
  throw new Error('unreachable');
}

Note that the client retries individual parts, not the whole upload. If part 12 of 200 fails on flaky WiFi, only that 10 MB chunk is retransmitted. The rest of the 2 GB file is already safely stored in S3 under the upload ID.

The security rules most people skip

Presigned URLs are powerful. A leaked presigned PUT URL is a temporary write credential. You need guardrails.

1. Scope the key, never trust the filename

Never let the client send the S3 key. The client sends metadata (filename, content type). The server generates the key using a fixed prefix and a random ID.

import { randomUUID } from 'node:crypto';

function generateKey(userId: string, filename: string) {
  const safe = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
  return `uploads/${new Date().toISOString().slice(0, 7)}/${userId}/${randomUUID()}-${safe}`;
}

This prevents path traversal, makes logs readable, and groups uploads by month for lifecycle policies.

2. Validate content type against an allowlist

const ALLOWED_TYPES = new Set([
  'video/mp4', 'video/webm', 'image/jpeg', 'image/png', 'application/pdf',
]);

function validateUploadRequest(contentType: string) {
  if (!ALLOWED_TYPES.has(contentType)) {
    throw new Error(`Content type not allowed: ${contentType}`);
  }
}

If you generate a presigned URL for image/png and the client tries to PUT an executable, S3 rejects it automatically. But you should also validate at the API layer so the client gets a clean 400 immediately instead of a confusing S3 XML error later.

3. Enforce a maximum file size

For single PUT uploads, pass ContentLength to the presigned URL command. For multipart, compute the part count from the client-reported size and reject if it exceeds your limit.

const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
const PART_SIZE = 10 * 1024 * 1024; // 10 MB

function validateSize(reportedBytes: number) {
  if (reportedBytes > MAX_FILE_SIZE) throw new Error('File too large');
  if (reportedBytes <= 0) throw new Error('Invalid size');
  return Math.ceil(reportedBytes / PART_SIZE);
}

The client can lie about the size, but the worst case is they get presigned URLs for more parts than they need. The actual S3 object size is bounded by the number of parts you generate times the part size. If they upload less, the final assembled object is smaller. You should still verify the final object size after completion if the exact size matters for downstream processing.

4. Short expiration, tight IAM

The presigned URL expiration should be just long enough for the slowest reasonable upload. For a 10 MB part on 1 Mbps hotel WiFi, that is about 90 seconds. Set the expiration to 10 minutes and move on.

The IAM policy attached to your server should not have s3:*. It should have exactly:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:AbortMultipartUpload",
        "s3:ListMultipartUploadParts",
        "s3:ListBucketMultipartUploads",
        "s3:GetObject"
      ],
      "Resource": [
        "arn:aws:s3:::your-bucket",
        "arn:aws:s3:::your-bucket/uploads/*"
      ]
    }
  ]
}

Notice the bucket policy is scoped to the uploads/* prefix. If your server is compromised, the attacker can only write to the upload prefix, not overwrite your application assets or read sensitive data from other prefixes.

5. Abort incomplete multipart uploads

If a client starts a multipart upload and then closes the browser, S3 keeps the parts forever (or until a lifecycle rule deletes them). Incomplete multipart uploads are invisible in the S3 console unless you know to look for them, and they cost money.

Add a lifecycle rule on the bucket:

{
  "Rules": [
    {
      "ID": "abort-incomplete-multipart",
      "Status": "Enabled",
      "Filter": { "Prefix": "uploads/" },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
    }
  ]
}

This aborts any multipart upload that is not completed within 24 hours. You can also abort explicitly from your server if the client signals cancellation.

export async function abortMultipartUpload(key: string, uploadId: string) {
  const command = new AbortMultipartUploadCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    UploadId: uploadId,
  });
  await s3.send(command);
}

Putting it together: the full upload router

// routes/uploads.ts
import { Router } from 'express';
import { randomUUID } from 'node:crypto';

const router = Router();
const ALLOWED_TYPES = new Set(['video/mp4', 'video/webm', 'image/jpeg', 'image/png']);
const MAX_SIZE = 5 * 1024 * 1024 * 1024;
const PART_SIZE = 10 * 1024 * 1024;

router.post('/small', async (req, res) => {
  const { filename, contentType } = req.body;
  if (!ALLOWED_TYPES.has(contentType)) return res.status(400).json({ error: 'bad type' });

  const key = generateKey(req.user.id, filename);
  const url = await createPresignedPutUrl(key, contentType, 100 * 1024 * 1024);
  res.json({ url, key });
});

router.post('/multipart/init', async (req, res) => {
  const { filename, contentType, size } = req.body;
  if (!ALLOWED_TYPES.has(contentType)) return res.status(400).json({ error: 'bad type' });
  if (size > MAX_SIZE) return res.status(400).json({ error: 'too large' });

  const key = generateKey(req.user.id, filename);
  const uploadId = await initiateMultipartUpload(key, contentType);
  const partCount = Math.ceil(size / PART_SIZE);
  res.json({ key, uploadId, partCount });
});

router.post('/multipart/urls', async (req, res) => {
  const { key, uploadId, partCount } = req.body;
  const urls = await createPresignedPartUrls(key, uploadId, partCount);
  res.json({ urls });
});

router.post('/multipart/complete', async (req, res) => {
  const { key, uploadId, parts } = req.body;
  await completeMultipartUpload(key, uploadId, parts);
  res.json({ key });
});

router.post('/multipart/abort', async (req, res) => {
  const { key, uploadId } = req.body;
  await abortMultipartUpload(key, uploadId);
  res.json({ ok: true });
});

export default router;

This is less than 60 lines. It handles small files, large files, cancellation, and type validation. The only infrastructure requirement is an S3 bucket with a lifecycle rule.

Testing the multipart flow locally

You do not need a browser to test this. The AWS CLI can exercise presigned URLs directly.

Generate a test file:

dd if=/dev/urandom of=test-50mb.bin bs=1M count=50

Start your server locally and use the multipart flow from a small Node.js script, or test each stage with curl:

# 1. Initiate
curl -s -X POST http://localhost:3000/api/uploads/multipart/init \
  -H 'Content-Type: application/json' \
  -d '{"filename":"test.bin","contentType":"application/octet-stream","size":52428800}'

# 2. Get URLs for the 5 parts
# 3. PUT each part to its presigned URL
# 4. Complete

For a fully automated test, mock the S3 client. The AWS SDK v3 is designed for this: every command is a plain object, and the client accepts a custom request handler.

import { S3Client } from '@aws-sdk/client-s3';

const mockS3 = new S3Client({
  requestHandler: {
    handle: async (request) => ({
      response: new Response('{}', { status: 200 }),
    }),
  },
});

Unit test your route handlers with a mocked S3 client. Integration test the full flow against LocalStack or a real S3 bucket in a sandbox account.

When this pattern does not fit

Direct-to-S3 upload is not universal. There are legitimate reasons to stream through your server:

  • Real-time processing. If you need to transcode a video, scan for malware, or extract metadata during upload, you may need the bytes to flow through a worker. Consider streaming from S3 to your processor after the direct upload completes instead. It is more steps, but it keeps the upload path fast and resilient.
  • Strict audit requirements. Some compliance regimes require every byte to pass through an audited middlebox. In that case, the architecture is different and you should budget for the infrastructure cost.
  • Tiny files in high volume. The overhead of three API round-trips (init, upload, complete) can exceed the cost of a single PUT through your server for files under 1 KB. Measure for your use case.

For everything else, direct-to-S3 is the default you should start from.

The takeaway

Uploading files through your API server is a tax on every layer of your stack: connections, memory, disk, CPU, timeouts, and your own time debugging why a 3 GB upload fails at 99%. The presigned URL pattern moves the bytes past your infrastructure entirely. Your server handles small JSON requests. S3 handles the storage. The client handles the retry logic per part.

The implementation is 60 lines of server code and 40 lines of client code. The security model is IAM plus short-lived scoped URLs. The failure mode is a single 10 MB part retrying instead of a 2 GB payload starting over. If your product team asks for large file uploads next quarter, this is the implementation that ships on Monday and survives the first traffic spike.


A note from Yojji

Moving large file uploads out of the application tier and into direct cloud storage is the kind of infrastructure decision that looks simple in hindsight but saves enormous operational cost in practice. Yojji’s teams build exactly these kinds of production backend patterns as standard practice, whether they are working with AWS, Azure, or Google Cloud, across full-cycle product builds and dedicated outstaffed engagements.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their engineers specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud-native architecture, and the kind of practical infrastructure work that keeps services fast under real load.