---
title: Tracking Upload Progress in Capacitor
description: Why onUploadProgress stays silent with the Capacitor HTTP plugin, and how to wire byte-accurate upload progress events to a progress bar in a Capacitor app.
date:
  created: 2026-11-06
  updated: 2026-11-06
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor File Transfer: sdks/capacitor/file-transfer.md
faq: true
---

# Tracking Upload Progress in Capacitor

Upload progress in a Capacitor app stops working the moment the Capacitor HTTP plugin is enabled, and the cause is in the patched request object: it never touches the `upload` property of `XMLHttpRequest`, so the `onUploadProgress` callback of Axios and a plain `xhr.upload.onprogress` handler never fire. This guide explains that gap, compares the three ways to get a progress bar back, and wires the byte-accurate `transferProgress` events of the [Capacitor File Transfer plugin](../../sdks/capacitor/file-transfer.md) to a progress bar, including completion, failure and what the bar shows after the app was relaunched. 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:**

- With `CapacitorHttp` enabled, Capacitor patches `XMLHttpRequest` but never dispatches events on `xhr.upload`, so upload progress handlers stay silent; issue #8241 about it has been open since November 2025.
- The patched `fetch` and `XMLHttpRequest` base64-encode every `File` in a `FormData` before handing the body to native code, so a large upload also costs WebView memory.
- Without `CapacitorHttp`, `xhr.upload.onprogress` works, but the body must be a `Blob` in JavaScript and the upload ends when the operating system suspends the app.
- `startUpload(...)` of the Capacitor File Transfer plugin streams the file from its path in a background `URLSession` on iOS and a foreground service on Android, and `transferProgress` reports `bytes`, `totalBytes` and `progress` about every 100 milliseconds.
- `transferCompleted` carries the `responseCode` and `responseBody` of an upload, and events that fire while no listener is attached are retained until one is added.

## Why handlers stay silent

The Capacitor HTTP plugin replaces `window.fetch` and `XMLHttpRequest` with versions that route requests through native code once `CapacitorHttp.enabled` is `true` in `capacitor.config`. The replacement is a reimplementation, not a proxy, and it leaves parts of the `XMLHttpRequest` contract out. Capacitor's `native-bridge.ts` never references `xhr.upload`, so no `progress`, `load` or `loadend` event is ever dispatched on it. Axios registers its `onUploadProgress` callback on exactly that object, and so does every hand-written `xhr.upload.addEventListener('progress', ...)`. The handlers are attached to an object nobody writes to.

The download side is not better. The patched `send()` dispatches one `progress` event on the request itself, after the whole response has arrived, with `loaded` and `total` both set to the length of the response data. A progress bar fed by that event jumps from zero to 100 percent at the end. [Issue #8241](https://github.com/ionic-team/capacitor/issues/8241){:target="_blank"} in the Capacitor repository describes the upload gap and names the cause: iOS uses a shared `URLSession` without a delegate, and Android wraps the body in a `RequestBody` that reports nothing. The issue was opened on November 14, 2025, closed and reopened on March 5, 2026, and received a reproduction repository on September 10, 2026; a fix has not been published.

A second effect of the patch matters for large files. When the body is a `FormData`, the patched request base64-encodes every `File` entry in JavaScript before the body crosses the bridge, so a 200 MB video becomes a 267 MB string in the WebView before the first byte leaves the device. The Capacitor HTTP documentation itself points at `@capacitor/file-transfer` for uploads and notes that "parsing and transferring large amount of data from native to the web can cause issues".

## Three ways to progress

A Capacitor app has three ways to show upload progress, and they differ in where the bytes come from and what happens when the app leaves the foreground:

| Approach | Progress source | Body | Background | Notes |
| --- | --- | --- | --- | --- |
| `XMLHttpRequest` without `CapacitorHttp` | `xhr.upload.onprogress`, `loaded` and `total` | `Blob` or `FormData` in WebView memory | Ends when iOS suspends the app | Free; `fetch` has no upload progress at all |
| `@capacitor/file-transfer` | `progress` event with `type: 'upload'`, `bytes` and `contentLength`, throttled to 100 ms | File path | Not documented | Official plugin, `uploadFile(...)` with `progress: true` |
| Capacitor File Transfer plugin | `transferProgress` with `bytes`, `totalBytes` and `progress`, about every 100 ms per transfer | File path, multipart or raw binary | Background `URLSession` on iOS, `dataSync` foreground service on Android | Insiders; retries, task store, S3 presigned URLs |

The first row is the one most apps start with, and it works until the file is large or the user switches apps. The WebView keeps the whole body, and iOS suspends the WebView's network activity together with the app. The second row is the Capacitor team's own plugin, whose [documentation](https://capacitorjs.com/docs/apis/file-transfer){:target="_blank"} describes a `progress` option that dispatches events "on every chunk received", throttled to 100 milliseconds on Android and iOS; it says nothing about background execution. The rest of this guide uses the third row, because a progress bar that keeps moving after the user opens another app is the point of the exercise.

## Start the upload

[`startUpload(...)`](../../sdks/capacitor/file-transfer.md#startupload) of the Capacitor File Transfer plugin takes the file's `path` on the device and resolves at once with the `id` of the transfer, while the upload runs natively. By default the plugin sends a `multipart/form-data` request with the file in a field named `file`:

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

const startVideoUpload = async (path: string, albumId: string) => {
  const { id } = await FileTransfer.startUpload({
    url: 'https://api.example.com/videos',
    path,
    title: 'Holiday video',
    mimeType: 'video/mp4',
    fileField: 'video',
    formFields: { albumId },
    headers: { Authorization: 'Bearer <token>' },
    maxRetries: 3,
  });
  return id;
};
```

The `path` is a native path, for example the `path` of a photo from `@capacitor/camera` or a file picked with the [Capacitor File Picker plugin](../../sdks/capacitor/file-picker.md), and the plugin streams it straight into the request body. No `Blob`, no base64 and no copy in JavaScript. `title` names the transfer in the Android notification, `maxRetries` retries after network errors with backoff, and `network: 'unmetered'` would hold the upload until the device is on Wi-Fi.

For an S3 presigned URL, switch to the raw body with `uploadType: 'binary'` and `method: 'PUT'`, since presigned URLs expect the file as the request body rather than a form. On Android, call [`setConfig(...)`](../../sdks/capacitor/file-transfer.md#setconfig) once at app start to name the notification and its channel in the user's language. On Android 13 and later, request the notification permission with [`requestPermissions()`](../../sdks/capacitor/file-transfer.md#requestpermissions) if the notification should appear; uploads run either way.

## Wire the progress bar

The [`transferProgress`](../../sdks/capacitor/file-transfer.md#addlistenertransferprogress-) event is where the progress bar gets its numbers. The plugin emits it about every 100 milliseconds per running transfer with `bytes` sent so far, `totalBytes`, and `progress` as a value from `0` to `1`. Both `totalBytes` and `progress` are `null` while the total is unknown. One listener serves every upload in the app when the UI keeps its elements in a map keyed by transfer `id`:

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

const progressBars = new Map<string, HTMLProgressElement>();

const trackUpload = (id: string, bar: HTMLProgressElement) => {
  progressBars.set(id, bar);
};

const listenForProgress = async () => {
  await FileTransfer.addListener('transferProgress', event => {
    const bar = progressBars.get(event.id);
    if (!bar) {
      return;
    }
    if (event.progress === null) {
      bar.removeAttribute('value');
    } else {
      bar.value = event.progress;
      bar.max = 1;
    }
  });
};
```

Removing the `value` attribute puts a native `<progress>` element into its indeterminate state, which is the honest display for an unknown total; Ionic's `ion-progress-bar` does the same with `type="indeterminate"`. For an upload the total is the file size and is known from the first event, so the indeterminate branch mostly covers the moment between `startUpload(...)` and the first progress event.

The throttle matters for the UI. One event per 100 milliseconds per transfer is ten updates per second, which a progress bar renders without work; a label that formats megabytes and seconds on every event is also fine at that rate. For an estimate of the remaining time, keep the timestamp and `bytes` of the previous event and divide the difference, then smooth it over a few events, since the first seconds of a transfer run faster than the average.

Register the listener at app startup, not when the upload screen opens. The plugin keeps uploading while the user navigates away, and a listener registered later only sees the events from that moment on.

## Finish or fail

Two more events end the story. [`transferCompleted`](../../sdks/capacitor/file-transfer.md#addlistenertransfercompleted-) fires once the server has answered with a success status and carries the `responseCode` and, for uploads, the `responseBody`, so the identifier your API returns for the new file is one `JSON.parse` away. [`transferFailed`](../../sdks/capacitor/file-transfer.md#addlistenertransferfailed-) fires after the last retry with an `errorCode`, a `message` and the `responseCode` if the server answered:

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

const listenForResults = async () => {
  await FileTransfer.addListener('transferCompleted', event => {
    const bar = progressBars.get(event.id);
    if (bar) {
      bar.value = 1;
      progressBars.delete(event.id);
    }
    if (event.responseBody) {
      const { videoId } = JSON.parse(event.responseBody);
      markUploaded(event.id, videoId);
    }
  });
  await FileTransfer.addListener('transferFailed', event => {
    progressBars.delete(event.id);
    showError(event.id, `${event.errorCode}: ${event.message}`);
  });
};
```

`markUploaded(...)` and `showError(...)` are your own functions. Completed and failed events that occur while no listener is registered are retained and delivered as soon as a listener is added, so an upload that finishes while the app is in the background still reaches your code on the next launch. That is the second reason the listeners belong at startup: a retained `transferCompleted` for an upload the user started yesterday arrives within the first seconds of the next session.

## After a relaunch

A progress bar also has to say something when the app comes back from a force-quit or an OS kill. [`getTransfers()`](../../sdks/capacitor/file-transfer.md#gettransfers) returns every persisted transfer with its `state`, `bytes` and `totalBytes`, so the upload list can be rebuilt before any event arrives:

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

const restoreUploads = async () => {
  const { transfers } = await FileTransfer.getTransfers();
  return transfers.filter(transfer => transfer.type === 'upload');
};
```

The states tell the UI what to render. A `running` upload on iOS continued in the background `URLSession` while the app was gone and reports progress again as soon as the listener is attached. An `interrupted` upload was stopped by the system, with `appTerminated` as the `interruptionReason` after a force-quit, an OS kill or a reboot, or `timeLimitReached` on Android 15 and later when the `dataSync` foreground service hit its daily limit of six hours. The plugin reports those through the `transferInterrupted` event on the next launch and leaves them waiting for [`resumeTransferById(...)`](../../sdks/capacitor/file-transfer.md#resumetransferbyid). Plain HTTP uploads have no standard way to continue from an offset, which is also why [`pauseTransferById(...)`](../../sdks/capacitor/file-transfer.md#pausetransferbyid) rejects for uploads with `TRANSFER_NOT_PAUSABLE`, so a resumed upload sends the file again from the first byte and the progress bar starts over. Render `interrupted` as "paused by the system" with a retry button rather than as an error.

`getTransfers()` only lists transfers in the `pending`, `running`, `paused`, `interrupted` and `failed` states after a cold start; `completed` and `canceled` records are removed when the app starts. The result of a finished upload reaches you through the retained `transferCompleted` event instead. [Downloading Files in the Background in Capacitor](./how-to-download-files-in-the-background-in-capacitor.md) goes through every app state and what survives it; the table there applies to uploads as well, minus pause and resume.

## Many uploads at once

Nothing in the plugin limits an app to one transfer, and the map keyed by `id` above already handles several bars. Two details change with multiple uploads. On Android, the single foreground service notification lists every running transfer by its `title` and shows the combined progress of all of them, so pass a `title` the user recognizes and set `hidden: true` for transfers that should not appear, such as a thumbnail that belongs to a video. And a combined progress bar for the whole batch is the sum of `bytes` over the sum of `totalBytes` across the transfers, which `getTransfers()` provides even for transfers started in an earlier session.

## FAQ

### Why does onUploadProgress do nothing with the Capacitor HTTP plugin?

Because the Capacitor HTTP plugin replaces `XMLHttpRequest` with a reimplementation that never dispatches events on the `upload` object. Axios and hand-written code attach their upload progress handlers there, so the handlers never run. Capacitor issue #8241, open since November 2025, tracks the gap. Use a transfer plugin that reports progress from native code, such as the [Capacitor File Transfer plugin](../../sdks/capacitor/file-transfer.md), or disable `CapacitorHttp` and accept that the upload then lives in the WebView.

### Does fetch report upload progress?

No. The Fetch API has no upload progress events in browsers or WebViews, with or without Capacitor. Upload progress in the WebView requires `XMLHttpRequest` and its `upload` object, and a native plugin outside the WebView.

### Does @capacitor/file-transfer report upload progress?

Its documentation describes a `progress` option on `uploadFile(...)` and a `progress` event whose `type` is `'download'` or `'upload'`, with `bytes`, `contentLength` and `lengthComputable`, throttled to 100 milliseconds on Android and iOS. It does not document background execution, so the upload shares the app's lifecycle.

### Does the progress bar keep updating in the background?

The upload keeps running in a background `URLSession` on iOS and a `dataSync` foreground service on Android, and on Android the notification shows the progress while the app is away. The `transferProgress` events reach your JavaScript again when the app returns to the foreground, and the listener picks up at the current `bytes` value.

### 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 with `cancelTransferById(...)` and start it again later.

### Is the Capacitor File Transfer plugin free?

No. It is part of the [Capawesome Insiders](../../insiders/index.md) subscription and installs from the Capawesome npm registry with your license key.

## Related posts

- [Announcing the Capacitor File Transfer Plugin](./announcing-the-capacitor-file-transfer-plugin.md)
- [Downloading Files in the Background in Capacitor](./how-to-download-files-in-the-background-in-capacitor.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

Upload progress on a real network, with the app in the background, only shows on a device, and the plugin's native engines ship in a native build. Capawesome Cloud builds and signs your Capacitor app for Android and iOS without a Mac, so the test build is minutes away. [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

Decide by file size and app lifecycle. A form with a 200 KB avatar can keep `XMLHttpRequest` and its `upload.onprogress`, as long as `CapacitorHttp` stays disabled for that request. Anything the user would mind losing when they switch apps belongs in the File Transfer plugin: register `transferProgress`, `transferCompleted`, `transferFailed` and `transferInterrupted` at startup, key the UI by transfer `id`, and rebuild the list from `getTransfers()` on every launch. For what happens to the same transfer when the app is suspended, killed or force-quit, 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"}.
