The resumable uploader

Your first Monday at StreamFlow. The CTO pulls you into a war room. Creators are rage-quitting because 8GB uploads die at 98 percent and have to start over. One viral tweet later, the CTO says fix this by Friday.

Constraints are non-negotiable. The server has 512MB of RAM so you cannot buffer the whole file in memory. Storage is local disk. The API must respect the HTTP Content-Range header. Upload sessions expire after 24 hours of inactivity. You must handle a mid-upload server restart without losing progress.

Resumable upload architecture

Client sends chunks with Content-Range. Server writes each chunk to the exact byte offset. On restart, server reads the current file size to know where to resume.

solution-1-low-ram/server.ts
typescript
import { createWriteStream } from 'fs';
import { stat } from 'fs/promises';
import { pipeline } from 'stream/promises';

app.patch('/upload/:id', async (req, res) => {
  const { id } = req.params;
  const range = req.headers['range'] || req.headers['content-range'];
  const [start] = parseRange(range);

  const path = './uploads/' + id;
  const out = createWriteStream(path, { flags: 'a', start });

  // pipeline handles backpressure automatically
  await pipeline(req, out);

  const { size } = await stat(path);
  res.json({ uploaded: size });
});

Stream the request body straight to a file at the correct byte offset. Memory stays constant.

solution-2-unstable-network/server.ts
typescript
// Each chunk is its own HTTP request. The id is the upload session.
app.post('/upload/:id/chunk/:index', async (req, res) => {
  const { id, index } = req.params;
  const chunkPath = './uploads/' + id + '/chunk-' + index;

  await pipeline(req, createWriteStream(chunkPath));
  await db.markChunkUploaded(id, Number(index));

  const missing = await db.getMissingChunks(id);
  res.json({ missing });
});

For flaky networks, accept one chunk at a time and track progress explicitly so clients can resume even if Content-Range parsing fails.

Two approaches, two tradeoffs. Solution A uses Content-Range and a single append-only file. Simple, fast, and the HTTP standard way. It assumes the client can recover from errors by retrying with a new range. Solution B uses explicit chunking and tracks missing chunks in a database. More moving parts, but it handles deeply unreliable networks where Content-Range based clients struggle. For YouTube at 512MB of RAM, solution A is usually the right answer.

Quiz: Quiz

Loading practice…

Validation checklist: Run the resumable uploader

Loading practice…