---
title: Downloading Files in the Background in Capacitor
description: Learn how a Capacitor background download survives backgrounding, OS kills and force-quits on Android and iOS, and how to restore it after a relaunch.
date:
  created: 2026-10-05
  updated: 2026-10-19
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor File Transfer: sdks/capacitor/file-transfer.md
faq: true
---

# Downloading Files in the Background in Capacitor

A download in a Capacitor app keeps running after the user leaves the app only when native code performs it, and how far it gets depends on how the app was closed. A Capacitor background download continues on Android and iOS while the app is backgrounded, survives an operating system kill on iOS, and is interrupted rather than lost when the user force-quits the app. This guide explains why downloads in JavaScript cannot do this, how the [Capacitor File Transfer plugin](../../sdks/capacitor/file-transfer.md) hands the work to the platform's transfer engines, and how your app picks up the result after a relaunch. The plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Thousands of teams ship faster with Capawesome Cloud Native Builds and Live Updates" src="https://capawesome.io/assets/banners/cloud-teams-ship-faster-with-capacitor.png" />
  </a>
</div>

**Key takeaways:**

- iOS gives `applicationDidEnterBackground(_:)` five seconds and then suspends the app, so a `fetch` or XHR download in the web view stops with the app's process.
- The Capacitor File Transfer plugin downloads through a background `URLSession` on iOS, which runs in a separate system process, and a `dataSync` foreground service on Android.
- After a low-memory kill, iOS finishes the download and delivers it on relaunch, while Android restores it as `interrupted` with the reason `appTerminated`, ready for `resumeTransferById(...)`.
- A force-quit cancels the transfer on iOS; since plugin version 0.2.0 it is restored as `interrupted` on relaunch and resumed from the task's resume data, for which `URLSession` sends the range request itself, so the server still needs range support.
- On Android 15 and higher, the `dataSync` foreground service may run 6 hours per day; past that, transfers are `interrupted` with the reason `timeLimitReached` until the app is in the foreground again.
- `transferCompleted`, `transferFailed` and `transferInterrupted` events that fire without a listener are retained, so listeners registered at startup receive results from the previous process.

## Why fetch stops

A download started with `fetch`, `XMLHttpRequest` or another JavaScript API runs in the web view inside your app's process, so it stops when iOS suspends that process. Apple's [background execution guide](https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time){:target="_blank"} spells out how quickly that happens:

> When your app moves to the background, the system calls your app delegate's applicationDidEnterBackground(_:) method. That method has five seconds to perform any tasks and return. Shortly after that method returns, the system puts your app into the suspended state.

`beginBackgroundTask(...)` buys extra time on top of that, but the time is finite, and Apple adds that "if you don't end your tasks in a timely manner, the system terminates your app." A large video rarely finishes downloading in that window on a mobile connection.

Background-mode plugins that keep the web view alive run into two problems. They work against Apple's rule that "a background app must do as little work as possible, and preferably nothing, because it's offscreen", and even when they succeed, the download still lives in the app's process. An OS kill or a force-quit ends it together with the app. The [background audio guide](./how-to-play-audio-in-the-background-in-capacitor.md) runs into the same suspension for HTML5 audio and solves it the same way, with native code.

The official Capacitor plugins do not change this picture on paper. The [`@capacitor/file-transfer`](https://capacitorjs.com/docs/apis/file-transfer){:target="_blank"} documentation (version 2.0.6 on npm) describes `downloadFile(...)` as a request that downloads "the file to the specified destination", and it does not document background, suspension or app lifecycle behavior anywhere on the page. The method returns a promise in your JavaScript, so a relaunched app has no reference to a download that the previous process started. [`@capacitor/http`](https://capacitorjs.com/docs/apis/http){:target="_blank"} patches `fetch` and `XMLHttpRequest` to use native libraries, does not document background execution either, and points large files to `@capacitor/file-transfer`.

## Native transfer engines

The Capacitor File Transfer plugin moves the download out of your app's process on iOS and into a component that Android allows to run in the background. Each platform gets the engine that the operating system designed for this job.

On iOS, the plugin uses a background `URLSession`. Apple's guide to [downloading files in the background](https://developer.apple.com/documentation/foundation/downloading-files-in-the-background){:target="_blank"} describes the property that makes this work:

> With background sessions, the actual transfer is performed by a process that is separate from your app's process.

Because the system process owns the transfer, iOS can suspend your app, or terminate it for memory, without touching the download. When the download finishes, iOS wakes or relaunches the app in the background to hand over the result. Apple documents one limit of this engine: for a transfer that an app starts while it is already in the background, the system [treats the session as discretionary](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/isdiscretionary){:target="_blank"} and decides when the transfer runs. Start important downloads while the app is in the foreground.

On Android, the plugin runs transfers in a `dataSync` foreground service driven by its own OkHttp engine. Android's [foreground service documentation](https://developer.android.com/develop/background-work/services/fgs){:target="_blank"} ties this kind of service to a visible status bar notification, "to make users aware that your app is performing a task in the foreground and is consuming system resources." That notification is the price of running while the app is backgrounded.

Android adds two rules on top. Apps that target Android 12 or higher [cannot start a foreground service](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start){:target="_blank"} while they run in the background. A transfer that starts in that situation, for example after waiting for Wi-Fi, runs without the foreground service and its notification since plugin version 0.1.2, instead of failing. Second, Android 15 introduced a [timeout for `dataSync` services](https://developer.android.com/about/versions/15/behavior-changes-15#datasync-timeout){:target="_blank"}: apps that target API level 35 or higher may run them for a total of 6 hours in a 24-hour period, shared by all of the app's `dataSync` services, and the timer resets when the user brings the app to the foreground. Since plugin version 0.2.0, reaching that limit stops the service and marks the running transfers as `interrupted` with the reason `timeLimitReached`; they can be resumed once the app is in the foreground again.

## Start a download

To install the Capacitor File Transfer plugin, please refer to the [Installation](../../sdks/capacitor/file-transfer.md/#installation) section in the plugin documentation. The plugin requires Capacitor 8 or later. Once it is installed, call [`setConfig(...)`](../../sdks/capacitor/file-transfer.md#setconfig) once at app start to name the Android notification, and [`startDownload(...)`](../../sdks/capacitor/file-transfer.md#startdownload) starts a download and resolves immediately with its identifier, while the transfer continues in native code:

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

const configureTransfers = async () => {
  await FileTransfer.setConfig({
    androidNotification: { title: 'Downloads', channelName: 'Downloads' },
  });
};

const downloadCoursePack = async (url: string, path: string) => {
  const { id } = await FileTransfer.startDownload({
    url,
    path,
    title: 'Course pack',
    network: 'unmetered',
    maxRetries: 3,
  });
  return id;
};
```

`url` is the source and `path` is the device path where the plugin stores the file. `headers` adds request headers such as `Authorization`, and `method` accepts `GET` (the default) or `POST`. The remaining options shape the background behavior:

- **`network`**: `'unmetered'` restricts the transfer to networks such as Wi-Fi and waits until one is available. On Android, the transfer stays in the `pending` state while it waits. The default is `'any'`.
- **`maxRetries`**: how often the plugin retries the transfer after a network error, with backoff. The default is `0`.
- **`title`**: the name of the transfer as the user sees it, for example as a line in the Android notification.
- **`hidden`**: `true` keeps the transfer out of that notification, for small files that belong to another transfer.

The Android notification is shared by all running transfers since plugin version 0.2.0. It lists every running transfer by its `title`, shows their combined progress, and takes its own title and `channelName` (both default to `File Transfer`) from `setConfig(...)`. That configuration is kept until the app restarts, so call it on every launch and again when the user changes the language. Versions before 0.2.0 took an `androidNotification` object per transfer with an optional `progress` flag; the object is gone, and the progress bar is always part of the notification.

Downloads are resumable by default, which requires the server to support the HTTP `Range` header; the [pause and resume section](../../sdks/capacitor/file-transfer.md#pause-and-resume-a-transfer) of the documentation covers the `resumable` option, and [Pause and Resume Downloads in Capacitor](./how-to-pause-and-resume-downloads-in-capacitor.md) explains what the server has to support. On the web, `startDownload(...)` rejects as unavailable, because transfers need a native background API.

## Listen for results

The Capacitor File Transfer plugin reports a download through four events instead of the promise of `startDownload(...)`, because the promise resolves before any byte arrives. Register the listeners once, at app startup, rather than on the screen that started the download:

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

const addTransferListeners = async () => {
  await FileTransfer.addListener('transferProgress', event => {
    console.log(`Transfer ${event.id}: ${event.progress ?? 'unknown'}`);
  });
  await FileTransfer.addListener('transferCompleted', event => {
    console.log(`Transfer ${event.id} saved to ${event.path}`);
  });
  await FileTransfer.addListener('transferFailed', event => {
    console.error(`Transfer ${event.id} failed: ${event.errorCode}`, event.message);
  });
  await FileTransfer.addListener('transferInterrupted', event => {
    console.warn(`Transfer ${event.id} interrupted: ${event.reason}`);
  });
};
```

[`transferProgress`](../../sdks/capacitor/file-transfer.md#addlistenertransferprogress-) fires roughly every 100 milliseconds per transfer with `bytes`, `totalBytes` and a `progress` value between 0 and 1, which is `null` when the server sends no size. [`transferCompleted`](../../sdks/capacitor/file-transfer.md#addlistenertransfercompleted-) carries the file `path` and the HTTP `responseCode`, and `transferFailed` carries an `errorCode`, a `message` and the `responseCode`. [`transferInterrupted`](../../sdks/capacitor/file-transfer.md#addlistenertransferinterrupted-) carries a `reason`, `appTerminated` or `timeLimitReached`, and marks a transfer the system stopped rather than one that failed.

The three final events behave differently from progress. The plugin retains completed, failed and interrupted events that occur while no listener is registered and delivers them once one is added, which is how an interruption caused by the termination of the app reaches the next app start. The documentation makes no such promise for progress events while the app is suspended, so base your logic on the final events and treat progress as display only.

## Platform setup

The Capacitor File Transfer plugin needs one change in your iOS project and none in your Android manifest. On iOS, the system relaunches a terminated app in the background when its URL session finishes, and Apple's [`application(_:handleEventsForBackgroundURLSession:completionHandler:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application%28_:handleeventsforbackgroundurlsession:completionhandler:%29){:target="_blank"} documentation describes the hand-off: "If a URL session finishes its work when your app is not running, the system launches your app in the background so that it can process the event." The plugin needs that completion handler, so you forward it from an `AppDelegate` extension:

```swift
import Foundation

extension AppDelegate {
    func application(
        _ application: UIApplication,
        handleEventsForBackgroundURLSession identifier: String,
        completionHandler: @escaping () -> Void
    ) {
        NotificationCenter.default.post(
            name: Notification.Name("io.capawesome.capacitorjs.plugins.filetransfer.handleEventsForBackgroundURLSession"),
            object: completionHandler
        )
    }
}
```

Without this hook, background transfers still complete, but iOS may not be able to wake the app to deliver the final events promptly. The [iOS section](../../sdks/capacitor/file-transfer.md#ios) of the documentation lists no `Info.plist` entry or background mode for the plugin.

On Android, the plugin declares the `INTERNET`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC` and `POST_NOTIFICATIONS` permissions and the `dataSync` service in its own manifest. The one runtime step is the notification permission on Android 13 and higher, which [`requestPermissions()`](../../sdks/capacitor/file-transfer.md#requestpermissions) requests:

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

const requestNotificationPermission = async () => {
  const { notifications } = await FileTransfer.requestPermissions();
  return notifications === 'granted';
};
```

A denied permission hides the notification, and the transfer still runs. Android's [notification permission guide](https://developer.android.com/develop/ui/views/notifications/notification-permission){:target="_blank"} adds that users then still see the foreground service in the Task Manager, only not in the notification drawer. On Android 12 and older and on iOS, the method resolves with `granted` without prompting.

## What survives what

A Capacitor background download with the Capacitor File Transfer plugin survives backgrounding on both platforms, survives an OS kill only on iOS, and is interrupted by a force-quit on both, with a resume on the next launch. The plugin's [FAQ on background behavior](../../sdks/capacitor/file-transfer.md#do-transfers-continue-when-the-app-is-in-the-background) documents each state, summarized here:

| App state | Android | iOS |
| --- | --- | --- |
| Backgrounded | Keeps running in the `dataSync` foreground service. | Keeps running in the background `URLSession`. |
| Background time limit reached | On Android 15 and higher, the `dataSync` service may run 6 hours per day; the transfers become `interrupted` with the reason `timeLimitReached`. | Not applicable. |
| Killed by the OS for memory | `interrupted` with the reason `appTerminated` and a `transferInterrupted` event on the next launch; downloads can be resumed. | Finished by the system and delivered when the app relaunches. |
| Force-quit by the user | Same as an OS kill. | Canceled by the system; on relaunch `interrupted` with the reason `appTerminated` and a `transferInterrupted` event, resumable from the task's resume data. |

The iOS force-quit row is an operating system rule. Apple's [`background(withIdentifier:)`](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/background%28withidentifier:%29){:target="_blank"} documentation states it in full:

> This behavior applies only for normal termination of the app by the system. If the user terminates the app from the multitasking screen, the system cancels all of the session's background transfers. In addition, the system does not automatically relaunch apps that were force quit by the user. The user must explicitly relaunch the app before transfers can begin again.

The plugin cannot restart a canceled download before the user opens the app again. Since version 0.2.0, it restores the transfer as `interrupted` on relaunch and keeps the resume data of the canceled task, so [`resumeTransferById(...)`](../../sdks/capacitor/file-transfer.md#resumetransferbyid) continues from where iOS stopped. Your code sets no `Range` header, because `URLSession` builds the range request from the resume data, but the server still has to support range requests, as [Pause and Resume Downloads in Capacitor](./how-to-pause-and-resume-downloads-in-capacitor.md) explains. The automatic retry that earlier versions tied to `maxRetries` is gone. Version 0.1.2 marked such a transfer as `failed` instead.

On Android, an interrupted download does not continue by itself either. It waits in the `interrupted` state until your code calls `resumeTransferById(...)`, which continues from the bytes already on disk when the server supports the HTTP `Range` header. The same call resumes `paused` and `failed` transfers.

## Restore after relaunch

After a relaunch, [`getTransfers()`](../../sdks/capacitor/file-transfer.md#gettransfers) returns every transfer the Capacitor File Transfer plugin knows about, including transfers restored from the previous process. Call it on startup, after registering the listeners, to rebuild your download list:

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

const restoreDownloads = async () => {
  const { transfers } = await FileTransfer.getTransfers();
  return transfers.filter(
    transfer => transfer.type === 'download' && transfer.state !== 'canceled',
  );
};
```

Each `Transfer` carries its `id`, `state`, `title`, `url`, `path`, `bytes` and `totalBytes`, plus an `errorCode` for a `failed` transfer and an `interruptionReason` for an `interrupted` one, which is enough to render a row per download without your own persistence layer. Completed and canceled transfers are removed on the next app start. A `running` download keeps reporting through `transferProgress`, a `pending` one waits for its network, a `failed` one is the candidate for a resume button, and an `interrupted` one can be resumed without asking, because the system stopped it rather than the user or an error:

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

const resumeInterruptedDownloads = async () => {
  const { transfers } = await FileTransfer.getTransfers();
  for (const transfer of transfers) {
    if (transfer.type === 'download' && transfer.state === 'interrupted') {
      await FileTransfer.resumeTransferById({ id: transfer.id });
    }
  }
};
```

Run that loop on every launch, after the listeners are registered, and again whenever the app returns to the foreground, for example from the `appStateChange` event of the [App plugin](https://capacitorjs.com/docs/apis/app){:target="_blank"}. A force-quit always ends in a relaunch, but the Android 15 time limit stops only the foreground service, while the app process keeps running, so the user often comes back to the same process and no startup code runs. The plugin allows the resume once the app is in the foreground again. Keep a resume action on `interrupted` rows as well, for the user who opens the download list before the loop ran.

The listener order matters for downloads that finished while the app was not running. A download that iOS completed after an OS kill produces a `transferCompleted` event in the relaunched app, and the plugin holds it until the first listener registers. If you register listeners only when the user opens the downloads screen, the event arrives there, possibly long after the file exists. The full set of transfer states is listed in the [`Transfer`](../../sdks/capacitor/file-transfer.md#transfer) reference.

## FAQ

### Do Capacitor downloads keep running when the app is closed?

It depends on how the app was closed. With the [Capacitor File Transfer plugin](../../sdks/capacitor/file-transfer.md), a download keeps running while the app is backgrounded on Android and iOS. If the OS kills the app for memory, iOS finishes the download and delivers it on relaunch, while Android restores it as `interrupted` and resumable. If the user force-quits the app, iOS cancels the download and the plugin restores it as `interrupted` on relaunch with the task's resume data; Android handles it like an OS kill. Resume interrupted transfers with `resumeTransferById(...)`. A download in `fetch` or XHR stops when iOS suspends the app.

### Does a background download on Android need a notification?

Yes, while it runs in the foreground service, because Android requires a notification for every foreground service. Since plugin version 0.2.0, all running transfers share one notification that lists them by `title` and shows their combined progress. The `POST_NOTIFICATIONS` permission only controls whether the notification is visible on Android 13 and higher, and the download runs either way. A download that starts while the app is in the background on Android 12 or higher runs without the service and its notification.

### What is the difference between interrupted and failed?

A `failed` transfer hit an error, such as a network error after its retries or an HTTP error response, and carries an `errorCode`. An `interrupted` transfer was stopped by the system, with the reason `appTerminated` after an OS kill, a force-quit or a device restart, or `timeLimitReached` after the daily `dataSync` limit on Android 15 and higher. The `interrupted` state and its `transferInterrupted` event exist since plugin version 0.2.0, and `resumeTransferById(...)` resumes both states.

### Does @capacitor/file-transfer download in the background?

The official `@capacitor/file-transfer` documentation does not document background or app lifecycle behavior. Its `downloadFile(...)` method returns a promise in JavaScript, so there is no documented way to pick up a download after the app was killed. [An Alternative to cordova-plugin-file-transfer](./alternative-to-cordova-plugin-file-transfer.md) compares it with the Capacitor File Transfer plugin.

### Do uploads continue in the background too?

Yes. [`startUpload(...)`](../../sdks/capacitor/file-transfer.md#startupload) uses the same engines and the same events as `startDownload(...)`. Only downloads can be paused and resumed in the current version.

### 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

- [Pause and Resume Downloads in Capacitor](./how-to-pause-and-resume-downloads-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)
- [How to Play Audio in the Background in a Capacitor App](./how-to-play-audio-in-the-background-in-capacitor.md)

## Try Capawesome Cloud

Background downloads need native code and an `AppDelegate` change on iOS, so shipping them takes a new native build rather than a web update. Capawesome Cloud builds and signs that app for Android and iOS without a Mac, and [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

Use a native transfer for any download that takes longer than a few seconds or that the user should be able to leave, and keep `fetch` for small requests that finish while the screen is open. Call `setConfig(...)` and register the listeners on every launch, resume every `interrupted` transfer that `getTransfers()` returns, and keep `maxRetries` for network errors rather than app restarts, because a force-quit on either platform now ends in a resumable `interrupted` transfer. For the files the downloads produce, [Capacitor File Handling: The Complete Guide](./capacitor-file-handling-guide.md) covers reading, moving and opening them.

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"}.
