System Design ·

Designing a File Upload Pipeline: Presigned URLs, Chunked Uploads, and Processing at Scale

Most file upload implementations start as a simple POST endpoint and collapse under real production load. This article covers the full architecture: presigned URLs for direct-to-storage uploads, chunked and resumable transfers for large files, virus scanning, async processing pipelines, CDN delivery, and the failure modes that surface at scale.

Designing a File Upload Pipeline: Presigned URLs, Chunked Uploads, and Processing at Scale

The file upload implementation in most production systems starts as a 20-line Express handler: accept a multipart request, pipe the body to S3, return a URL. That works until you need to handle 500MB video files, or until your API server starts running out of memory because three concurrent uploads are buffering through it simultaneously, or until a user on a flaky mobile connection loses their 200MB upload at 98% and has to start over.

The real architecture is not complicated, but it has more moving parts than the naive version. This article walks through every layer: direct-to-storage uploads with presigned URLs, chunked and resumable transfers, validation and virus scanning, async processing queues, CDN delivery, and the failure modes you will hit in production.

Why You Should Not Buffer Uploads Through Your API Server

The instinctive approach routes upload traffic through your application:

// What most teams start with
app.post('/upload', upload.single('file'), async (req, res) => {
  const file = req.file; // multer has buffered this into memory
  await s3.putObject({
    Bucket: process.env.S3_BUCKET!,
    Key: `uploads/${file.originalname}`,
    Body: file.buffer,
  });
  res.json({ url: `https://cdn.example.com/uploads/${file.originalname}` });
});

This has three problems. First, every byte of every upload passes through your application process, consuming CPU for I/O work that your storage layer is better equipped to handle. Second, you cannot horizontally scale cleanly: uploads to one instance cannot be accessed by another for any post-processing. Third, streaming large files correctly requires careful Node.js stream handling that most teams skip, so you end up with files buffered entirely in memory.

The better path: generate a signed URL, send the client directly to storage, and have your application only handle the coordination.

Presigned URLs for Direct-to-Storage Uploads

A presigned URL is a temporary credential baked into a URL. The client uploads directly to S3 (or R2, or GCS) with no traffic touching your API server.

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

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

interface PresignRequest {
  filename: string;
  contentType: string;
  contentLength: number;
  userId: string;
}

interface PresignResponse {
  uploadId: string;
  uploadUrl: string;
  key: string;
  expiresAt: Date;
}

async function createPresignedUpload(req: PresignRequest): Promise<PresignResponse> {
  // Validate before generating the URL
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'application/pdf'];
  if (!allowedTypes.includes(req.contentType)) {
    throw new Error(`Content type not allowed: ${req.contentType}`);
  }

  const maxBytes = 100 * 1024 * 1024; // 100MB for single-part
  if (req.contentLength > maxBytes) {
    throw new Error('File too large for single-part upload, use multipart');
  }

  const uploadId = randomUUID();
  const ext = req.filename.split('.').pop() ?? 'bin';
  // Use a structured key: user-scoped, not guessable from filename alone
  const key = `uploads/${req.userId}/${uploadId}.${ext}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    ContentType: req.contentType,
    ContentLength: req.contentLength,
    Metadata: {
      'upload-id': uploadId,
      'user-id': req.userId,
      'original-filename': encodeURIComponent(req.filename),
    },
  });

  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 900 }); // 15 minutes

  return {
    uploadId,
    uploadUrl,
    key,
    expiresAt: new Date(Date.now() + 900_000),
  };
}

The client receives this URL and PUTs directly to S3:

// Client-side upload
async function uploadFile(file: File, presign: PresignResponse): Promise<void> {
  const response = await fetch(presign.uploadUrl, {
    method: 'PUT',
    headers: {
      'Content-Type': file.type,
      'Content-Length': String(file.size),
    },
    body: file,
  });

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
  }
}

After the PUT completes, the client notifies your API. This is where you record the upload in your database and kick off async processing.

// API handler: confirm upload completed
app.post('/uploads/:uploadId/confirm', async (req, res) => {
  const { uploadId } = req.params;

  // Verify the object actually landed in S3
  const headResult = await s3.headObject({
    Bucket: process.env.S3_BUCKET!,
    Key: req.body.key,
  });

  await db.insert(uploads).values({
    id: uploadId,
    userId: req.user.id,
    key: req.body.key,
    contentType: headResult.ContentType!,
    sizeBytes: headResult.ContentLength!,
    status: 'pending',
    createdAt: new Date(),
  });

  // Enqueue for processing
  await queue.send('file-processing', { uploadId, key: req.body.key });

  res.json({ uploadId, status: 'pending' });
});

Chunked and Resumable Uploads for Large Files

For files over 100MB, presigned single-part uploads break down. Network interruptions on large files are common, and retrying the entire transfer is a poor experience. S3 Multipart Upload solves this: you split the file into parts, upload each independently, and finalize with a completion call. If a part fails, you retry only that part.

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

const PART_SIZE = 10 * 1024 * 1024; // 10MB minimum enforced by S3

interface MultipartSession {
  uploadId: string;
  s3UploadId: string;
  key: string;
  totalParts: number;
}

async function initiateMultipartUpload(
  key: string,
  contentType: string,
  fileSizeBytes: number,
): Promise<MultipartSession> {
  const { UploadId } = await s3.send(
    new CreateMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      ContentType: contentType,
    }),
  );

  const totalParts = Math.ceil(fileSizeBytes / PART_SIZE);
  const uploadId = randomUUID();

  // Persist session so it survives page reloads
  await db.insert(multipartSessions).values({
    id: uploadId,
    s3UploadId: UploadId!,
    key,
    totalParts,
    completedParts: [],
    createdAt: new Date(),
  });

  return { uploadId, s3UploadId: UploadId!, key, totalParts };
}

The client uploads each part using a presigned URL generated per-part:

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

async function getPartUploadUrl(
  key: string,
  s3UploadId: string,
  partNumber: number,
): Promise<string> {
  const command = new UploadPartCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    UploadId: s3UploadId,
    PartNumber: partNumber,
  });

  return getSignedUrl(s3, command, { expiresIn: 3600 });
}

// Client-side: upload a single part with retry
async function uploadPart(
  url: string,
  chunk: Blob,
  onProgress: (bytes: number) => void,
): Promise<string> {
  const response = await fetch(url, {
    method: 'PUT',
    body: chunk,
  });

  if (!response.ok) throw new Error(`Part upload failed: ${response.status}`);

  // ETag is required for the completion call
  const etag = response.headers.get('ETag');
  if (!etag) throw new Error('Missing ETag in part response');

  onProgress(chunk.size);
  return etag;
}

Once all parts are uploaded, finalize:

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

async function completeMultipartUpload(
  key: string,
  s3UploadId: string,
  parts: Array<{ PartNumber: number; ETag: string }>,
): Promise<void> {
  await s3.send(
    new CompleteMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      UploadId: s3UploadId,
      MultipartUpload: { Parts: parts },
    }),
  );
}

Resumability comes from persisting the completed parts list. When a user returns after an interruption, fetch the session from your database, generate presigned URLs only for the missing parts, and continue. S3 holds incomplete multipart uploads for up to 7 days by default. Add a lifecycle rule to abort them sooner or you will accumulate storage costs from abandoned uploads.

Validation and Virus Scanning

You cannot trust content-type headers. Clients send whatever they want. Validate based on actual file content using magic bytes:

async function detectMimeType(key: string): Promise<string> {
  // Read only the first 8KB: enough for magic byte detection
  const { Body } = await s3.getObject({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    Range: 'bytes=0-8191',
  });

  const buffer = Buffer.from(await Body!.transformToByteArray());

  // JPEG: FF D8 FF
  if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
  // PNG: 89 50 4E 47 0D 0A 1A 0A
  if (
    buffer[0] === 0x89 && buffer[1] === 0x50 &&
    buffer[2] === 0x4e && buffer[3] === 0x47
  ) return 'image/png';
  // PDF: 25 50 44 46
  if (buffer.slice(0, 4).toString() === '%PDF') return 'application/pdf';
  // MP4: check ftyp box
  if (buffer.slice(4, 8).toString() === 'ftyp') return 'video/mp4';

  return 'application/octet-stream';
}

For virus scanning, integrate ClamAV (self-hosted) or a cloud scanning API. The pattern is the same: download the file from your private bucket, scan, mark the record, and either quarantine or delete on detection.

interface ScanResult {
  clean: boolean;
  threat?: string;
}

async function scanFile(key: string): Promise<ScanResult> {
  const { Body } = await s3.getObject({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
  });

  const buffer = Buffer.from(await Body!.transformToByteArray());

  // Example using a hypothetical scanning client
  // In practice: ClamAV via clamdjs, or an HTTP API like VirusTotal
  const result = await virusScanner.scan(buffer);

  if (!result.clean) {
    // Move to quarantine bucket, do not delete (for forensics)
    await s3.copyObject({
      CopySource: `${process.env.S3_BUCKET}/${key}`,
      Bucket: process.env.S3_QUARANTINE_BUCKET!,
      Key: key,
    });
    await s3.deleteObject({ Bucket: process.env.S3_BUCKET!, Key: key });
  }

  return result;
}

Keep your upload bucket private. Only the finalized, scanned content should ever be publicly accessible, and only through a CDN with a separate origin bucket.

Async Processing Pipeline

Uploads land in a private bucket. Processing (image resizing, video transcoding, metadata extraction) happens asynchronously. The architecture: a job queue, one or more worker types, and status polling.

interface ProcessingJob {
  uploadId: string;
  key: string;
  contentType: string;
  operations: ProcessingOperation[];
}

type ProcessingOperation =
  | { type: 'scan' }
  | { type: 'resize'; widths: number[] }
  | { type: 'transcode'; format: 'mp4' | 'webm'; maxBitrate: number }
  | { type: 'extract-metadata' };

// Worker: processes one job at a time
async function processUpload(job: ProcessingJob): Promise<void> {
  await db.update(uploads)
    .set({ status: 'processing' })
    .where(eq(uploads.id, job.uploadId));

  try {
    // Step 1: always scan first
    const scan = await scanFile(job.key);
    if (!scan.clean) {
      await db.update(uploads)
        .set({ status: 'rejected', rejectionReason: `Threat detected: ${scan.threat}` })
        .where(eq(uploads.id, job.uploadId));
      return;
    }

    // Step 2: validate actual mime type
    const detectedType = await detectMimeType(job.key);
    if (detectedType !== job.contentType) {
      await db.update(uploads)
        .set({ status: 'rejected', rejectionReason: 'Content type mismatch' })
        .where(eq(uploads.id, job.uploadId));
      return;
    }

    // Step 3: content-type-specific processing
    const outputs: Record<string, string> = {};

    for (const op of job.operations) {
      if (op.type === 'resize' && detectedType.startsWith('image/')) {
        const resized = await resizeImage(job.key, op.widths);
        outputs['thumbnails'] = JSON.stringify(resized);
      }
      if (op.type === 'extract-metadata') {
        const meta = await extractMetadata(job.key, detectedType);
        outputs['metadata'] = JSON.stringify(meta);
      }
    }

    // Move processed file to public bucket
    const publicKey = job.key.replace('uploads/', 'public/');
    await s3.copyObject({
      CopySource: `${process.env.S3_BUCKET}/${job.key}`,
      Bucket: process.env.S3_PUBLIC_BUCKET!,
      Key: publicKey,
    });

    await db.update(uploads).set({
      status: 'ready',
      publicKey,
      outputs,
      processedAt: new Date(),
    }).where(eq(uploads.id, job.uploadId));

  } catch (err) {
    await db.update(uploads)
      .set({ status: 'failed', lastError: String(err) })
      .where(eq(uploads.id, job.uploadId));
    throw err; // Let the queue handle retry
  }
}

Status polling from the client:

// Client polls until status is terminal
async function pollUploadStatus(uploadId: string): Promise<Upload> {
  const terminalStatuses = new Set(['ready', 'failed', 'rejected']);
  let backoff = 1000;

  while (true) {
    const upload = await api.get<Upload>(`/uploads/${uploadId}`);
    if (terminalStatuses.has(upload.status)) return upload;

    await new Promise(resolve => setTimeout(resolve, backoff));
    backoff = Math.min(backoff * 1.5, 10_000);
  }
}

For long-running jobs like video transcoding, consider WebSocket or SSE push instead of polling. Polling works fine for processing times under 30 seconds.

CDN Delivery

Serve public files through a CDN, not directly from S3. Direct S3 URLs expose your bucket name, bypass caching, and incur per-request costs. A CDN origin pointed at your public bucket gives you edge caching, signed URL support for gated content, and a stable URL structure that survives bucket migrations.

For gated content (files that require authentication to view), generate time-limited signed CDN URLs server-side:

import { createHmac } from 'crypto';

function signCdnUrl(key: string, expiresInSeconds: number): string {
  const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
  const path = `/files/${key}`;
  const signature = createHmac('sha256', process.env.CDN_SECRET!)
    .update(`${path}:${expiresAt}`)
    .digest('hex');

  return `https://cdn.example.com${path}?expires=${expiresAt}&sig=${signature}`;
}

Tradeoffs

ApproachThroughputResumabilityComplexityBest For
Buffer through APILowNoLowPrototypes, < 5MB files
Presigned PUTHighNoMediumFiles < 100MB, simple flow
S3 MultipartHighYesHighFiles > 100MB, mobile clients
Tus protocol (self-hosted)HighYesVery HighFull resumability standard

Multipart upload is the right default for anything above 100MB. Below that, a presigned PUT is simpler and covers most use cases.

Production Failure Modes

Incomplete multipart uploads accumulating cost. S3 charges for the storage consumed by in-progress multipart uploads. An S3 lifecycle rule like AbortIncompleteMultipartUploads after 1 day prevents this from becoming a surprise line item.

Client reporting upload success when S3 never received it. Always verify with HeadObject in your confirmation handler. Do not trust the client’s claim that the upload succeeded.

Processing jobs stuck in “processing” forever. Workers crash. Set a max processing time and a watchdog query that requeues any upload stuck in processing for longer than N minutes.

Presigned URLs used for unauthorized file types. The presigned URL grants a PUT with the exact ContentType and ContentLength you specified. A client can still upload different bytes. This is why magic-byte validation in the processing step is non-negotiable.

Race condition between confirm and processing. If your confirmation handler enqueues the job and the worker fires before the database transaction commits, the worker reads no record. Use transactional outbox: insert the job record in the same transaction as the upload record, and have a separate poller dispatch it.

CDN serving stale content after file replacement. If you allow re-uploads to the same key, invalidate the CDN cache explicitly. Better: use content-addressed keys (hash of the file contents), which makes cache invalidation irrelevant.

What This Looks Like in Practice

The end-to-end flow for a 50MB image upload:

  1. Client requests a presigned URL from your API, sending filename, content type, and file size.
  2. API validates the request, generates a key, persists an upload record with status initiated, and returns the presigned URL.
  3. Client PUTs the file directly to S3.
  4. Client calls your confirmation endpoint with the upload ID and key.
  5. API runs HeadObject, confirms the file is there and matches expected size, updates status to pending, and enqueues a processing job.
  6. Worker picks up the job, scans, validates mime type, resizes, copies to the public bucket, updates status to ready.
  7. Client polls status until ready, then gets the CDN URL for display.

The API server handles only small JSON requests. Storage handles the bytes. Workers handle the computation. Each layer scales independently.

The naive POST-to-API handler is not wrong for a prototype. It is wrong for anything that needs to survive real upload volumes, large files, or users on mobile connections. The architecture above is not significantly harder to build the first time if you approach it with the right primitives. The cost of retrofitting it later, when you are debugging memory exhaustion on your API servers at 2 AM, is much higher.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.