---
title: Resumable uploads
description: Pause, resume, and abort a large upload via a control handle, or resume it in a later process from a serializable session token. A per-call option on upload.
related:
  - /guides/resume-s3-multipart-upload
---

A long upload over a flaky connection shouldn't have to start over when it's interrupted. Pass an [`UploadControl`](/docs/api/upload) to [`upload`](/docs/api/upload) and you can `pause()` it, `resume()` it, or `abort()` it — and, because the control holds a **serializable session token**, you can persist it and resume the upload in a later process, after a crash, or on the next page load.

```ts lineNumbers
import { Files, UploadControl } from "files-sdk";

const control = new UploadControl();
const result = files.upload("backups/db.tar", file, {
  control,
  multipart: { partSize: 16 * 1024 * 1024 },
  onProgress: ({ loaded, total }) =>
    console.log(total ? `${Math.round((loaded / total) * 100)}%` : `${loaded}`),
});

control.pause(); // in-flight parts settle; `result` stays pending
control.resume(); // pick up where it left off
await result;
```

It's an [`AbortSignal`](/docs/cancellations)-style handle: a plain object you construct and hand in, then drive from the outside. The `upload` call returns its usual promise, which resolves when the upload completes (and stays pending while paused).

## Resuming across processes

`control.toJSON()` returns a small JSON-serializable token describing the provider-side session. Persist it wherever you like — a file on disk, a database row keyed by the user's upload. Later, rebuild a control from it with `UploadControl.from(token)` and call `upload` again with the **same body**: the SDK discovers what already landed server-side and uploads only the rest.

```ts lineNumbers
import { readFile, writeFile } from "node:fs/promises";

// First run — pause and persist.
const control = new UploadControl();
files.upload("backups/db.tar", file, { control }).catch(() => {});
// …once a session exists, control.toJSON() is populated…
await writeFile("upload.json", JSON.stringify(control.toJSON()));

// Later — a new process, after a crash.
const token = JSON.parse(await readFile("upload.json", "utf8"));
const result = await files.upload("backups/db.tar", file, {
  control: UploadControl.from(token),
});
```

Because resuming re-reads the bytes, `control` requires a body with a **known length** — a `File`, `Blob`, `ArrayBuffer`, typed array, or `string`. A bare `ReadableStream` is rejected: a consumed stream can't be replayed.

:::warning
`UploadControl` runs wherever the `Files` instance runs, which holds your provider credentials, so in practice that's your server, a worker, or a CLI. The browser client (`files-sdk/client`) and [`useFiles`](/docs/ui) don't use it: in 3.x they send each file through your [gateway](/docs/ui/server/gateway) or a presigned URL in a single request, can't pause, and can't resume across a page reload. An interrupted browser upload starts again from zero.
:::

## Pause, abort, and the session

- **`pause()`** stops dispatching new parts. Parts already in flight finish, then the upload waits. The session is preserved, so this is the moment to `toJSON()` and persist.
- **`resume()`** continues a paused upload.
- **`abort()`** cancels the upload **and discards the provider-side session** — the partial upload a provider might otherwise bill or retain is cleaned up. It's terminal: the token can no longer be resumed.

Aborting via the [`signal`](/docs/cancellations) option instead cancels the call but _keeps_ the session, so you can resume it later. `control.status` (`"idle"`, `"uploading"`, `"paused"`, `"completed"`, `"aborted"`, `"error"`) and `control.loaded` / `control.total` track progress for a UI.

### Discarding a persisted session

`abort()` discards the session through the adapter, and a control only has one while `upload()` is driving it. A control rebuilt with `UploadControl.from(token)` that you never pass to `upload()` can't reach the provider: its `abort()` only marks it aborted, and the session stays behind. To give up on a persisted upload, pass its key and token to `files.abortUpload()` instead:

```ts lineNumbers
const token = JSON.parse(await readFile("upload.json", "utf8"));
await files.abortUpload("backups/db.tar", token);
```

It does what `abort()` does mid-upload. The key and token must match (the same logical key the upload targeted, on an instance with the same adapter and prefix), or it throws before discarding anything. A session that's already gone, because the upload completed or was discarded, isn't an error, but a cancel the provider refuses rejects: on GCS, Firebase Storage, Google Drive, OneDrive, SharePoint, and Supabase, any response other than a 2xx, 404, 410, or 499 fails the call instead of resolving. Like other writes, it throws `ReadOnly` on a [read-only](/docs/readonly) instance, and it throws `Unsupported` on an adapter without resumable uploads. It reports through the [hooks](/docs/api/onaction) as `type: "abortUpload"`, but plugin `wrap`s don't see it.

## What each adapter does

The token maps onto whatever resumable primitive the provider exposes. Most adapters resume **across processes** — persist the token, resume after a crash:

- **S3 and the S3-compatible adapters** (incl. R2 over HTTP with the `"aws-sdk"` client) drive S3's native multipart API directly — the token carries the `UploadId`, resume lists already-uploaded parts with `ListParts`, and `abort()` issues `AbortMultipartUpload`. (This is a separate path from the [`multipart`](/docs/multipart) flag's `@aws-sdk/lib-storage` upload, which doesn't expose the upload id.)
- **GCS**, **Firebase Storage**, and **Google Drive** resume against a stored resumable-session URI.
- **Azure Blob** stages blocks and commits them, skipping blocks already staged on resume.
- **OneDrive** and **SharePoint** drive a Graph upload session and read `nextExpectedRanges` to resume.
- **Dropbox** drives an upload session, tracking the byte offset in the token.
- **Vercel Blob** drives its native multipart API; the token carries the completed parts (it exposes no server-side list).
- **The local filesystem** writes a `.fls-part` temp file and resumes from its size, renaming it into place on completion.
- **FTP** and **SFTP**, like the local filesystem, append to a `.fls-part` staging file next to the key and resume from its size, renaming it over the key on completion. A paused or aborted upload never exposes a partial object, and `list()` hides the staging files.
- **Supabase** drives the resumable [TUS](https://tus.io) endpoint; **Appwrite** and **Cloudinary** drive their chunked `Content-Range` uploads.

A few providers expose no serializable session, so they support pause/resume **in-process only** — `control.toJSON()` can't be resumed in a new process: **Box** (its commit requires a whole-file digest), **bun-s3**, and **memory** (no upload id). They buffer the body in the running process and upload on completion.

What `abort()` and `files.abortUpload()` discard also depends on the provider. S3 and the S3-compatible adapters abort the multipart upload; GCS, Firebase Storage, Google Drive, OneDrive, SharePoint, and Supabase cancel the session URL; the local filesystem, FTP, and SFTP delete the `.fls-part` file; and Appwrite deletes the partially uploaded file (never one whose upload has completed). Azure, Dropbox, Cloudinary, and Vercel Blob have no way to cancel a session, so it expires on its own (Azure drops uncommitted blocks after about 7 days), and the in-process sessions above end with their process.

Every remaining adapter — Netlify Blobs, UploadThing, PocketBase, Bunny, Convex, WebDAV, the R2 Workers binding, the `fetch` S3 engine (`s3Fetch()` and `client: "fetch"`), and the rest — throws an `Unsupported` [`FilesError`](/docs/api/errors) when `control` is passed, the same way an unsupported [range download](/docs/api/download) does. Check [`files.capabilities.resumable`](/docs/capabilities) to branch up front. `control` is a single-key option; it isn't available in the [array form](/docs/bulk) of `upload`.

> The Supabase (TUS), Appwrite, and Cloudinary drivers are built to each provider's documented chunked-upload protocol and covered by mocked tests, but haven't been exercised against a live account — verify them end-to-end before relying on them in production.

## Parts, retries, and progress

`partSize` and `concurrency` come from the [`multipart`](/docs/multipart) option and tune the same trade-off; both must be positive integers, or the upload throws an `Invalid` [`FilesError`](/docs/api/errors) before a session opens. Each part (or chunk) is retried on its own under the call's [retry](/docs/retries) policy, so a transient failure mid-upload doesn't restart the whole transfer. [`onProgress`](/docs/api/onprogress) fires as parts confirm, and on resume it starts from the bytes already uploaded.

Opening the session, checking it on resume, and finalizing it follow the same rules as the parts: each attempt gets the per-attempt [`timeout`](/docs/timeouts), stops on the caller's `signal` or `control.abort()`, and is retried on a transient provider error. A session that opens after its attempt timed out or was aborted is discarded as soon as it lands, since no one holds its token. If a finalize attempt fails and its retry finds the session already gone, the first attempt may have committed the object: the error says so (with the first failure as its `cause`), so check with [`head`](/docs/api/head) before uploading again.

The SDK also refuses to finalize a body it didn't fully send. A chunk the provider acknowledges without moving its offset forward counts as a failed attempt (retried within the budget) rather than being re-sent forever, and a part list that doesn't add up to the whole body is never committed.
