---
title: Pause and Resume Downloads in Capacitor
description: Learn how to pause and resume a download in a Capacitor app, what the server must support, and how a paused download survives a force-quit on Android and iOS.
date:
  created: 2026-10-15
  updated: 2026-10-15
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor File Transfer: sdks/capacitor/file-transfer.md
faq: true
---

# Pause and Resume Downloads in Capacitor

A Capacitor pause and resume download works when three parties agree: the server has to support byte ranges and identify the file version, the native download engine has to keep the bytes it already has together with a token to continue, and your app has to find the paused transfer again after a restart. The [Capacitor File Transfer plugin](../../sdks/capacitor/file-transfer.md) covers the second and third part, and this guide explains the first, with the exact HTTP headers and status codes involved, how pause and resume differ on Android and iOS, and what a user sees after they force-quit the app with a paused download. The plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Try Capawesome Cloud free for 14 days — iOS builds without a Mac" src="https://capawesome.io/assets/banners/cloud-free-trial-capacitor-apps.png" />
  </a>
</div>

**Key takeaways:**

- A download can only be resumed if the server supports byte ranges, sends an `ETag` or `Last-Modified` header, and still serves the same file; Apple and the plugin's Android engine both enforce this.
- `pauseTransferById(...)` works for downloads only; iOS keeps Apple's resume data as a file and Android keeps the partial file plus the validator header.
- A paused download stays `paused` across a force-quit on both platforms, and `getTransfers()` returns it on the next launch for a manual `resumeTransferById(...)`.
- On resume, Android sends `Range` and `If-Range`; a `206` appends, a `200` restarts from zero, and a `416` fails the transfer.
- The plugin fires no paused or resumed event, so the UI updates its own state when the promise resolves.

## What resumable means

A resumable download is one that can continue from the byte where it stopped instead of starting over, and the conditions for that are set by the server, not by the app. Apple lists them in the documentation of [`cancel(byProducingResumeData:)`](https://developer.apple.com/documentation/foundation/urlsessiondownloadtask/cancel(byproducingresumedata:)){:target="_blank"}: the resource has not changed since the first request, the task is an HTTP or HTTPS `GET` request, the server provides an `ETag` or `Last-Modified` header, the server supports byte-range requests, and the temporary file has not been deleted by the system under disk pressure. Android's engine in the Capacitor File Transfer plugin checks the same conditions in its own way, which the resume section covers.

On the client side, two things decide whether a download is pausable. The `resumable` option of [`startDownload(...)`](../../sdks/capacitor/file-transfer.md#startdownload) defaults to `true` and can be turned off for a file that must always be fetched whole, and a download that was canceled rather than paused has no partial data left. The plugin's engines, a background `URLSession` on iOS and OkHttp in a foreground service on Android, are the subject of [Downloading Files in the Background in Capacitor](./how-to-download-files-in-the-background-in-capacitor.md); this guide assumes that setup and looks only at the state of a single transfer.

## Check the server

Checking whether a server supports resumable downloads takes one request, because the answer is in the response. MDN's guide to [HTTP range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests){:target="_blank"} says that an `Accept-Ranges` header with any value other than `none` advertises range support, but a `HEAD` request only shows what the server advertises, and some servers answer `HEAD` and `GET` with different headers. Ask for the first byte of the real file instead, which is the request the plugin sends on resume:

```bash
curl -r 0-0 -sS -o /dev/null -D - https://cdn.example.com/course-pack.zip
```

Three things in the answer matter. A `206` status with a `content-range` header means the server honors a `Range` request on this file, a `200` with the whole body means it does not, and `etag` or `last-modified` gives the client a validator that identifies the file version. A server that answers with a `206` without either validator is a server where iOS produces no resume data and the plugin's Android engine restarts from zero, because, as the plugin's source puts it, resuming without a validator "risks splicing two different revisions of the file together."

The status codes on resume follow from the same guide. A valid range request gets a `206 Partial Content` with a `Content-Range` header, a server that does not support ranges answers `200 OK` with the entire body, and a range past the end of the file gets `416 Range Not Satisfiable`. The `If-Range` header ties the range to the validator: if the file still matches, the server sends the range with a `206`; if it changed, it sends the whole new file with a `200`. CDNs and object storage usually pass all of this through, but a CDN configured to strip `ETag` headers for cache reasons turns every resume into a restart, so test the real URL rather than the origin.

## Pause a download

The Capacitor File Transfer plugin pauses a running download with [`pauseTransferById(...)`](../../sdks/capacitor/file-transfer.md#pausetransferbyid), which stops the transfer, keeps what was downloaded, and moves the transfer to the `paused` state:

```typescript
import { FileTransfer } from '@capawesome-team/capacitor-file-transfer';

const pauseDownload = async (id: string) => {
  await FileTransfer.pauseTransferById({ id });
};
```

Pausing is for downloads only. The method rejects with `TRANSFER_NOT_PAUSABLE` for an upload, because plain HTTP uploads have no standard way to continue, and for a download started with `resumable: false`. An unknown identifier rejects with `TRANSFER_NOT_FOUND`, and a transfer that has no running task, for example one that already failed or an Android download still waiting for an unmetered network, resolves without changing its state.

What the plugin keeps differs per platform. On iOS, it calls Apple's `cancel(byProducingResumeData:)` and writes the returned resume data to a file in the app's caches directory, named after the transfer identifier. That blob is Apple's token for continuing; if the server did not meet the conditions above, Apple returns no resume data, the transfer still becomes `paused`, and a later resume starts from zero. On Android, the partial data is already in the destination file at `path`, because the engine writes straight to it, and the validator from the first response is stored with the transfer record. Both platforms persist the `paused` state itself, in `UserDefaults` on iOS and `SharedPreferences` on Android, before the promise resolves.

## Resume a download

[`resumeTransferById(...)`](../../sdks/capacitor/file-transfer.md#resumetransferbyid) continues a transfer in the `paused` or `failed` state, and resolves without effect for every other state:

```typescript
import { FileTransfer } from '@capawesome-team/capacitor-file-transfer';

const resumeDownload = async (id: string) => {
  await FileTransfer.resumeTransferById({ id });
};
```

On iOS, the plugin looks for the resume data file. If it exists, it creates a new task with `downloadTask(withResumeData:)`, and Apple's download delegate reports the offset it continued from. If no file exists, the plugin starts a fresh request from the first byte. Either way, the resume data file is deleted afterwards. Apple's note on the resume API is the caveat to remember: a download "may also restart if the file on the server has been modified, or if the temporary file has been deleted because of low disk space", and both cases look like a normal download to your app.

On Android, the plugin sends an HTTP request with two headers when the partial file exists, the validator is known and the download is resumable:

```http
Range: bytes=52428800-
If-Range: "a1b2c3d4"
```

A `206` response appends to the file and takes the total size from `Content-Range`. A `200` response means the server either does not support ranges or the file changed; the plugin truncates the file, stores the new validator and downloads from the start. A `416` and any other status outside the 2xx range end the transfer with `HTTP_ERROR` and the `failed` state. A `failed` download is resumable again with the same method, since the partial bytes are still on disk, and this is how a download that Android interrupted in the background continues, which the background guide covers in its state table.

The two platforms diverge on one case: a download that was `running` when the user force-quit the app. Android marks it `failed` on the next launch and resumes it from the bytes in the file. iOS cancels the task with the app, and the plugin keeps resume data only when it paused the download itself, so a manual resume of that `failed` download starts from zero on iOS. Set `maxRetries` above 0 for downloads the user started, so the plugin retries on relaunch instead.

## Cancel versus pause

[`cancelTransferById(...)`](../../sdks/capacitor/file-transfer.md#canceltransferbyid) ends a `pending`, `running` or `paused` transfer and deletes the partial data, so nothing can be resumed afterwards. On Android, the partial data is the destination file itself, so canceling deletes the file at `path`. On iOS, the plugin cancels the task and removes any resume data.

Use pause when the user might come back and cancel when they will not. A paused download costs disk space for the partial file and nothing else; a canceled one frees the space and the entry. The state rules have one gap: cancel resolves without effect for a `failed`, `completed` or `canceled` transfer, so a `failed` download cannot be dismissed from the list. It stays in [`getTransfers()`](../../sdks/capacitor/file-transfer.md#gettransfers) until it is resumed and completes, and on Android its partial file stays at `path`. Hide failed transfers the user gave up on in your own UI state, and delete the partial file with `@capacitor/filesystem` if the space matters.

## Restore after force-quit

A paused download survives a force-quit on both platforms, because the Capacitor File Transfer plugin persists the `paused` state and the data it needs to continue, and nothing in the restart logic touches paused transfers. The download does not continue by itself: the operating system cannot restart it before the user opens the app again, and the plugin waits for your call. On launch, read the known transfers and offer a resume button for the paused ones:

```typescript
import { FileTransfer } from '@capawesome-team/capacitor-file-transfer';

const restorePausedDownloads = async () => {
  const { transfers } = await FileTransfer.getTransfers();
  return transfers.filter(
    transfer => transfer.type === 'download' && transfer.state === 'paused',
  );
};

const resumeAll = async () => {
  const paused = await restorePausedDownloads();
  for (const transfer of paused) {
    await FileTransfer.resumeTransferById({ id: transfer.id });
  }
};
```

`getTransfers()` after a cold start only returns transfers in the `pending`, `running`, `paused` and `failed` states. The plugin deletes `completed` and `canceled` records when it loads, so a finished download from the previous session is not in the list; its result arrives through the retained `transferCompleted` event instead, which is why the listeners belong at app startup, before this call. Each `Transfer` carries `bytes` and `totalBytes`, so the restored row can show the same progress bar it showed before the restart, without a database of your own.

The resume data on iOS lives in the caches directory, which the system may clear under storage pressure. A paused download whose resume data is gone still resumes, from the first byte, and the plugin reports nothing special about it. Show the restored progress as "paused at 48 MB" and let the progress events correct it once the download runs again.

## Build the UI state

The Capacitor File Transfer plugin emits three events, `transferProgress`, `transferCompleted` and `transferFailed`, and none of them reports a pause or a resume. A pause changes the state in native storage when the promise of `pauseTransferById(...)` resolves, and that promise is the moment to update your UI:

```typescript
import { FileTransfer, Transfer } from '@capawesome-team/capacitor-file-transfer';

const togglePause = async (transfer: Transfer): Promise<Transfer> => {
  if (transfer.state === 'running') {
    await FileTransfer.pauseTransferById({ id: transfer.id });
  } else if (transfer.state === 'paused' || transfer.state === 'failed') {
    await FileTransfer.resumeTransferById({ id: transfer.id });
  }
  const { transfer: updated } = await FileTransfer.getTransferById({ id: transfer.id });
  return updated;
};
```

[`getTransferById(...)`](../../sdks/capacitor/file-transfer.md#gettransferbyid) returns the persisted record, which is more reliable than assuming the new state, since a pause on a transfer without a running task leaves the state unchanged. After a resume, `transferProgress` continues with `bytes` counted from the offset, so a progress bar computed as `bytes / totalBytes` picks up where it stopped. On Android, a resumed transfer passes through `pending` before it is `running`, for a moment or for as long as it waits for an unmetered network; render `pending` as "waiting" rather than as an error.

## FAQ

### Can a paused download be resumed after the app is force-quit?

Yes, on Android and iOS. The [Capacitor File Transfer plugin](../../sdks/capacitor/file-transfer.md) stores the `paused` state together with the partial file and the validator on Android, and with Apple's resume data on iOS. After the relaunch, `getTransfers()` lists the download as `paused`, and `resumeTransferById(...)` continues it, provided the server still supports ranges and serves the same file. The download does not resume on its own.

### Why does my resume start from zero?

Because one of the resume conditions failed. The usual causes are a server without `Accept-Ranges: bytes`, a missing `ETag` and `Last-Modified` header, a file that changed on the server (the `If-Range` check then returns a `200`), a `POST` download on iOS, or resume data that iOS deleted under storage pressure. Check the real file with a one-byte range request, as shown above, and make sure `resumable` was not set to `false`.

### Can I pause an upload?

No. `pauseTransferById(...)` rejects with `TRANSFER_NOT_PAUSABLE` for uploads, because plain HTTP uploads have no standard mechanism to continue from an offset. Cancel the upload and start it again when needed.

### Does a paused download survive a device reboot?

The paused record and the partial file on Android do; iOS resume data in the caches directory survives a reboot unless the system cleared the cache for space. In both cases, call `resumeTransferById(...)` after the next launch; a resume without resume data starts from the beginning.

### Is the Capacitor File Transfer plugin free?

No. The plugin is part of the [Capawesome Insiders](../../insiders/index.md) subscription and is installed from the Capawesome npm registry with the license key that comes with it.

## Related posts

- [Downloading Files in the Background in Capacitor](./how-to-download-files-in-the-background-in-capacitor.md)
- [Announcing the Capacitor File Transfer Plugin](./announcing-the-capacitor-file-transfer-plugin.md)
- [An Alternative to cordova-plugin-file-transfer](./alternative-to-cordova-plugin-file-transfer.md)
- [Capacitor File Handling: The Complete Guide](./capacitor-file-handling-guide.md)

## Try Capawesome Cloud

Pause and resume behavior only shows on a real device with a real network, and the plugin's native engines ship in a native build. Capawesome Cloud builds and signs your app for Android and iOS without a Mac, so the test build is minutes away after each change. [How to Sign & Build Capacitor Apps in the Cloud](./how-to-sign-and-build-your-capacitor-app-in-the-cloud.md) shows the steps.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Check the file server with a one-byte range request before you add a pause button, and only offer the button when the answer is a `206` with a `Content-Range` header and a validator. Serve large files with stable `ETag` headers through a CDN that keeps them, set `maxRetries` above 0 on iOS for downloads the user started, and rebuild the download list from `getTransfers()` on every launch, with a resume action for `paused` and `failed` rows. For how the download keeps running while the app is in the background, read [Downloading Files in the Background in Capacitor](./how-to-download-files-in-the-background-in-capacitor.md).

If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
