Resumable S3 Multipart Uploads with Uppy

Use @uppy/aws-s3 with shouldUseMultipart enabled for large files and implement its five hooks against your own API: createMultipartUpload (your server calls S3 and returns uploadId and key), signPart (returns a presigned URL for one part), listParts (returns parts S3 already has, for resuming), completeMultipartUpload and abortMultipartUpload. Uppy then uploads parts in parallel with retries, tracks progress, and — with the Golden Retriever plugin — restores in-progress uploads after a reload by calling listParts and continuing. Keep every S3 call on your server so the browser never holds credentials and every part URL is scoped to one upload ID and part number.

Writing a multipart uploader from scratch means handling part numbering, ETags, concurrency, retries, pausing, progress aggregation and resume, before you get to the UI. Uppy packages all of that behind a small set of hooks, leaving you in control of authorisation and object naming. This page belongs to resumable upload state machines in frontend UX, chunking and progress tracking; the server-side S3 calls are covered in depth in S3 multipart upload orchestration.

When to use this approach

  • Files are large (hundreds of megabytes to many gigabytes) and go to S3 or an S3-compatible store.
  • You want resumable, parallel uploads without writing the part scheduler yourself.
  • You also want a ready-made UI (Dashboard) or are happy to drive Uppy’s state from your own components.

Prerequisites

  1. Uppy 4.x: @uppy/core, @uppy/aws-s3, and optionally @uppy/dashboard and @uppy/golden-retriever.
  2. An API with five endpoints backed by @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner.
  3. Bucket CORS that allows PUT from your origin and exposes ETag — without it, Uppy cannot read part ETags and completion fails.
  4. A lifecycle rule to abort incomplete multipart uploads (expiring incomplete multipart uploads automatically).

Who calls what

Uppy multipart hooks mapped to server endpoints and S3 operations Uppy in the browser calls five hooks. createMultipartUpload calls your API which calls S3 CreateMultipartUpload. signPart asks your API for a presigned UploadPart URL, then Uppy PUTs the part directly to S3. listParts calls ListParts through your API when resuming. completeMultipartUpload and abortMultipartUpload call the matching S3 operations through your API. Your API holds the credentials; Uppy moves the bytes Uppy hooks createMultipartUpload signPart listParts completeMultipartUpload abortMultipartUpload your API POST /multipart GET /multipart/:id/:part GET /multipart/:id POST /multipart/:id/complete DELETE /multipart/:id S3 CreateMultipartUpload UploadPart (presigned) ListParts CompleteMultipartUpload AbortMultipartUpload part bytes go straight to S3 with the presigned URL
Five small endpoints give Uppy everything it needs without exposing credentials.

Implementation

Browser:

import Uppy from "@uppy/core";
import AwsS3 from "@uppy/aws-s3";
import GoldenRetriever from "@uppy/golden-retriever";

const api = async (method: string, path: string, body?: unknown, signal?: AbortSignal) => {
  const res = await fetch(`/api/uploads${path}`, {
    method, signal, credentials: "include",
    headers: body ? { "Content-Type": "application/json" } : undefined,
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`${method} ${path}: ${res.status}`);
  return res.status === 204 ? undefined : res.json();
};

export const uppy = new Uppy({ restrictions: { maxFileSize: 20 * 1024 ** 3, maxNumberOfFiles: 50 } })
  .use(GoldenRetriever, { serviceWorker: false })
  .use(AwsS3, {
    shouldUseMultipart: (file) => (file.size ?? 0) > 100 * 1024 * 1024,
    limit: 4,                                        // parallel requests across all files
    getChunkSize: (file) => Math.max(8 * 1024 * 1024, Math.ceil((file.size ?? 0) / 9000)),

    createMultipartUpload: (file) =>
      api("POST", "/multipart", { name: file.name, type: file.type, size: file.size }),

    signPart: (file, { uploadId, key, partNumber, signal }) =>
      api("GET", `/multipart/${encodeURIComponent(uploadId)}/${partNumber}?key=${encodeURIComponent(key)}`, undefined, signal),

    listParts: (file, { uploadId, key, signal }) =>
      api("GET", `/multipart/${encodeURIComponent(uploadId)}?key=${encodeURIComponent(key)}`, undefined, signal),

    completeMultipartUpload: (file, { uploadId, key, parts, signal }) =>
      api("POST", `/multipart/${encodeURIComponent(uploadId)}/complete?key=${encodeURIComponent(key)}`, { parts }, signal),

    abortMultipartUpload: (file, { uploadId, key, signal }) =>
      api("DELETE", `/multipart/${encodeURIComponent(uploadId)}?key=${encodeURIComponent(key)}`, undefined, signal),

    // Small files: a single presigned PUT.
    getUploadParameters: (file) => api("POST", "/single", { name: file.name, type: file.type, size: file.size }),
  });

uppy.on("upload-success", (file, res) => confirmUpload(file!.meta, res.uploadURL));
uppy.on("upload-error", (file, err) => console.warn(file?.name, err.message));

Server (Express with AWS SDK v3):

import { S3Client, CreateMultipartUploadCommand, UploadPartCommand, ListPartsCommand,
         CompleteMultipartUploadCommand, AbortMultipartUploadCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({});
const Bucket = process.env.BUCKET!;
const owns = (req: any, key: string) => key.startsWith(`uploads/${req.user.id}/`);

app.post("/api/uploads/multipart", async (req, res) => {
  const { type, size } = req.body;
  if (!(size > 0 && size <= 20 * 1024 ** 3)) return res.status(400).end();
  const Key = `uploads/${req.user.id}/${randomUUID()}`;
  const out = await s3.send(new CreateMultipartUploadCommand({ Bucket, Key, ContentType: type }));
  res.json({ uploadId: out.UploadId, key: Key });
});

app.get("/api/uploads/multipart/:uploadId/:part", async (req, res) => {
  const key = String(req.query.key); const PartNumber = Number(req.params.part);
  if (!owns(req, key) || !(PartNumber >= 1 && PartNumber <= 10000)) return res.status(403).end();
  const url = await getSignedUrl(s3, new UploadPartCommand({ Bucket, Key: key, UploadId: req.params.uploadId, PartNumber }), { expiresIn: 900 });
  res.json({ url, expires: 900 });
});

app.get("/api/uploads/multipart/:uploadId", async (req, res) => {
  const key = String(req.query.key);
  if (!owns(req, key)) return res.status(403).end();
  const parts: any[] = []; let marker: string | undefined;
  do {
    const out = await s3.send(new ListPartsCommand({ Bucket, Key: key, UploadId: req.params.uploadId, PartNumberMarker: marker }));
    parts.push(...(out.Parts ?? []));
    marker = out.IsTruncated ? out.NextPartNumberMarker : undefined;
  } while (marker);
  res.json(parts);                                   // [{ PartNumber, Size, ETag }]
});

app.post("/api/uploads/multipart/:uploadId/complete", async (req, res) => {
  const key = String(req.query.key);
  if (!owns(req, key)) return res.status(403).end();
  const Parts = req.body.parts.map((p: any) => ({ PartNumber: p.PartNumber, ETag: p.ETag })).sort((a: any, b: any) => a.PartNumber - b.PartNumber);
  const out = await s3.send(new CompleteMultipartUploadCommand({ Bucket, Key: key, UploadId: req.params.uploadId, MultipartUpload: { Parts } }));
  res.json({ location: out.Location });
});

app.delete("/api/uploads/multipart/:uploadId", async (req, res) => {
  const key = String(req.query.key);
  if (!owns(req, key)) return res.status(403).end();
  await s3.send(new AbortMultipartUploadCommand({ Bucket, Key: key, UploadId: req.params.uploadId })).catch(() => {});
  res.status(204).end();
});

Line-by-line on the decisions that matter

  • Ownership check on every endpoint. The key and upload ID come back from the browser, so each endpoint verifies the key sits under the user’s prefix before signing or completing. Without it, a user could sign parts for someone else’s upload ID.
  • Chunk size from file size. S3 allows 10,000 parts. getChunkSize keeps parts at least 8 MiB and large enough that very big files stay under the limit (a 100 GB file gets roughly 11 MB parts).
  • limit: 4. Uppy’s limit covers all requests from the plugin, across files and parts. Four keeps the uplink busy without starving other traffic; see upload queue concurrency control for tuning.
  • listParts paginates. S3 returns at most 1,000 parts per call. Large uploads resumed without pagination look incomplete and Uppy re-uploads parts it already sent.
  • Sorted parts on completion. S3 requires ascending part numbers. Uppy sends them in order, but sorting server-side makes the endpoint robust to any client.
  • Golden Retriever. The plugin stores Uppy’s state (and, when possible, file blobs) in IndexedDB. After a reload it restores files and, for multipart uploads, calls listParts to skip completed parts. serviceWorker: false keeps it simpler; the service-worker mode can also keep blobs across browser restarts.

Resuming after a reload

Upload recovery with Golden Retriever While uploading, Golden Retriever saves Uppy state including upload IDs and keys to IndexedDB. After a reload, it restores the files. For files whose blobs were saved, upload resumes automatically: listParts returns completed parts and only the missing parts are uploaded. For files whose blobs were lost, the user is asked to add the same file again. Restore state, ask S3 what it has, send the rest state saved uploadId, key, blob reload listParts parts 1–37 present upload 38–120 then complete blob missing → ask user to re-add the same file
The upload ID survives the reload; S3 is the source of truth for which parts arrived.

Browsers limit how much Golden Retriever can store. Small files are kept as blobs in IndexedDB; very large ones may exceed quota, and some browsers evict storage under pressure. When a blob is missing after a restore, Uppy shows the file as a “ghost” and asks the user to add it again; when they do, it matches by name, size and type and resumes the existing multipart upload. Make that prompt clear in your UI — “Add trip.mov again to continue where you left off” — rather than letting the ghost look like an error.

Completing safely

CompleteMultipartUpload is where problems surface: missing parts, wrong ETags, parts under 5 MiB. After it succeeds, confirm the object with your API — HeadObject for size and content type — and record it, as in confirming uploads before committing database records. For integrity, enable S3 checksums: sign x-amz-checksum-crc32 per part in signPart and pass Uppy the computed value, or rely on S3’s automatic CRC for new uploads and compare the full-object checksum after completion. Binding checksums into presigned PUT URLs explains the signing side.

Abort is equally important. Users cancel, tabs crash, and some uploads are simply abandoned. Uppy calls abortMultipartUpload on cancel, but abandoned uploads never call anything; the lifecycle rule is what stops invisible parts from costing money indefinitely.

How multipart uploads end A multipart upload ends in one of three ways. Completion assembles the object and should be followed by a confirmation call. Cancellation calls abort and frees the parts immediately. Abandonment leaves parts in storage until the lifecycle rule aborts the upload after a few days. Every upload must end one of three ways completed object assembled then confirm + record cancelled abort call parts freed now abandoned no call at all lifecycle rule aborts Without the lifecycle rule, the third column quietly accumulates storage charges.
Plan for the ending nobody triggers explicitly.

Configuration gotchas

Completion fails with InvalidPart or ETags are null. Bucket CORS does not expose ETag. Add it to ExposeHeaders; Uppy reads it from each part’s response.

Resumed uploads re-send every part. listParts returned an empty or truncated list — often missing pagination, or the ownership check rejected the request. Check the endpoint returns all parts with PartNumber, Size and ETag.

EntityTooSmall on completion. A non-final part was under 5 MiB, usually from a custom getChunkSize. Keep the minimum at 5 MiB or more.

Signed part URLs expire mid-part on slow links. Increase expiresIn for part URLs to cover the slowest expected part, or rely on Uppy re-requesting a URL on retry; see recovering from expired presigned URLs mid-upload.

Verification

  • Upload a 1 GB file and watch the network panel: at most four part PUTs in flight, each directly to S3.
  • Reload the page halfway: after restore, one listParts call, then only the missing parts upload.
  • Cancel an upload and run aws s3api list-multipart-uploads --bucket $BUCKET: it is gone.
  • Try signing a part with another user’s key via curl: the API returns 403.

Frequently Asked Questions

Do I need Companion, Uppy’s server?

No. Companion is useful for remote sources (Google Drive, Dropbox, URLs) and can handle S3 signing for you, but your own five endpoints are enough for local files and keep authorisation in your code.

Can I use Uppy without its Dashboard UI?

Yes. Uppy’s core and plugins work headless; subscribe to its events and render your own list, keeping the patterns from accessible upload interfaces.

Does this work with R2 or MinIO?

Yes, with the S3 client pointed at their endpoints. R2 requires equal part sizes except the last, so keep getChunkSize constant per file.