DEV Community

Robin for Capawesome

Posted on Originally published at capawesome.io

Downloading Files in the Background in Capacitor

If your Capacitor app downloads a large file with fetch, the download dies as soon as the user switches apps for more than a few seconds. In this post, we look at why that happens, how to hand the download to the native transfer engines of Android and iOS, and what your app sees after the OS kills it or the user force-quits it.

Prerequisites

Why fetch stops

A download started with fetch, XMLHttpRequest or any other JavaScript API runs in the web view, inside your app's process. When iOS suspends that process, the download stops with it. Apple's background execution guide is clear about how fast 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.

You can request extra time with beginBackgroundTask(...), but that time is finite, and Apple warns that "if you don't end your tasks in a timely manner, the system terminates your app." A large video on a mobile connection rarely makes it.

Background-mode plugins that keep the web view alive go against Apple's rule that "a background app must do as little work as possible, and preferably nothing, because it's offscreen". Even when they work, an OS kill or a force-quit still ends the download together with the app.

The official plugins don't solve this either. The @capacitor/file-transfer docs (version 2.0.6 on npm) describe downloadFile(...) as downloading "the file to the specified destination" and say nothing about background or lifecycle behavior. The method returns a promise in JavaScript, so a relaunched app has no reference to a download the previous process started.

Native transfer engines

The Capacitor File Transfer plugin gives each platform the engine the OS designed for this job.

On iOS, it uses a background URLSession. Apple's guide to downloading files in the background explains why this works:

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

iOS can suspend your app, or terminate it for memory, without touching the download, and then wakes or relaunches the app in the background to hand over the result. One limit: if your app starts a transfer while it's already in the background, the system treats the session as discretionary and decides when it runs. Start important downloads in the foreground.

On Android, the plugin runs transfers in a dataSync foreground service with its own OkHttp engine. Android's foreground service docs require a visible notification, "to make users aware that your app is performing a task in the foreground and is consuming system resources."

Android adds two more rules:

  • Apps targeting Android 12+ can't start a foreground service from the background. Since plugin version 0.1.2, a transfer that starts in that situation (for example, after waiting for Wi-Fi) runs without the service and its notification instead of failing.
  • Android 15 added a dataSync timeout: apps targeting API level 35+ get 6 hours per 24-hour period, shared across all their dataSync services. The timer resets when the user brings the app to the foreground.

Start a download

For installation, see the Installation section of the plugin docs. Once it's set up, startDownload(...) resolves immediately with an ID while the transfer continues in native code:

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

const downloadCoursePack = async (url: string, path: string) => {
  const { id } = await FileTransfer.startDownload({
    url,
    path,
    network: 'unmetered',
    maxRetries: 3,
    androidNotification: {
      title: 'Downloading course pack',
      text: 'The download continues in the background.',
      progress: true,
    },
  });
  return id;
};
Enter fullscreen mode Exit fullscreen mode

Besides url and path, you can pass headers and method. The background behavior comes from three options:

  • network: 'unmetered' waits for Wi-Fi or a similar network. On Android, the transfer sits in the pending state meanwhile. Default is 'any'.
  • maxRetries: how often to retry after a network error, with backoff. Default is 0, which becomes important after an iOS force-quit.
  • androidNotification: title and text of the foreground service notification. progress: true adds a per-transfer progress bar, and channelName defaults to File Transfer.

Downloads are resumable by default if the server supports the HTTP Range header (see pause and resume).

Listen for results

Since the promise resolves before a single byte arrives, the plugin reports progress and results through events. Register the listeners once at app startup, not on the screen that started the download:

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);
  });
};
Enter fullscreen mode Exit fullscreen mode

transferProgress fires about every 100 ms with bytes, totalBytes and a progress value between 0 and 1 (null if the server sends no size). transferCompleted carries the path and HTTP responseCode, and transferFailed carries errorCode, message and responseCode.

The plugin retains completed and failed events that fire while no listener is registered and delivers them once one is added. There's no such guarantee for progress events during suspension, so build your logic on the final events and treat progress as display only.

Platform setup

On iOS, you need one change. When a URL session finishes while your app isn't running, Apple's application(_:handleEventsForBackgroundURLSession:completionHandler:) docs explain that "the system launches your app in the background so that it can process the event." Forward that completion handler to the plugin from an AppDelegate extension:

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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Without it, transfers still complete, but iOS may not wake the app to deliver the final events promptly. No Info.plist entry or background mode is needed.

On Android, the plugin's own manifest declares the permissions and the dataSync service. The only runtime step is the notification permission on Android 13+, via requestPermissions():

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

const requestNotificationPermission = async () => {
  const { notifications } = await FileTransfer.requestPermissions();
  return notifications === 'granted';
};
Enter fullscreen mode Exit fullscreen mode

If the user denies it, the notification is hidden but the transfer still runs.

What survives what

How far a download gets depends on how the app was closed:

App state Android iOS
Backgrounded Keeps running in the dataSync foreground service. Keeps running in the background URLSession.
Killed by the OS for memory Interrupted, then restored as failed; 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; retried on relaunch if maxRetries is above 0, otherwise restored as failed with a transferFailed event.

The iOS force-quit row comes straight from Apple's background(withIdentifier:) docs:

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.

So the retry can only happen on relaunch, and only with maxRetries above 0. On Android, an interrupted download waits in failed until you call resumeTransferById(...), which continues from the bytes already on disk.

Restore after relaunch

getTransfers() returns every transfer the plugin knows about, including ones restored from the previous process. Call it on startup, after registering your listeners:

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',
  );
};
Enter fullscreen mode Exit fullscreen mode

Each Transfer has its id, state, url, path, bytes and totalBytes, so you don't need your own persistence layer. A running download keeps reporting progress, a pending one waits for its network, and a failed one gets a resume button.

Listener order matters here. A download that iOS completed after an OS kill produces a transferCompleted event on relaunch, held until the first listener registers. If you only register listeners on the downloads screen, that event shows up there, possibly long after the file exists.

Conclusion

Use a native transfer for any download that takes more than a few seconds or that the user should be able to walk away from, and keep fetch for small requests that finish while the screen is open. Set maxRetries above 0 for user-started downloads so an iOS force-quit costs a relaunch instead of the download, and call getTransfers() on every launch so interrupted Android downloads get their resume button.

The full post has an FAQ and more details, and if you have questions, drop them in the comments.

Top comments (0)