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
- Uppy 4.x:
@uppy/core,@uppy/aws-s3, and optionally@uppy/dashboardand@uppy/golden-retriever. - An API with five endpoints backed by
@aws-sdk/client-s3and@aws-sdk/s3-request-presigner. - Bucket CORS that allows
PUTfrom your origin and exposesETag— without it, Uppy cannot read part ETags and completion fails. - A lifecycle rule to abort incomplete multipart uploads (expiring incomplete multipart uploads automatically).
Who calls what
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.
getChunkSizekeeps 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.listPartspaginates. 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
listPartsto skip completed parts.serviceWorker: falsekeeps it simpler; the service-worker mode can also keep blobs across browser restarts.
Resuming after a reload
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.
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
listPartscall, 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.