react-native-continued-task README

The project's README from github.com/mahdidavoodi7/react-native-continued-task. As one markdown file: README.md.

react-native-continued-task: Continued Background Tasks for React Native (iOS 26 & Android)

npm npm downloads license platforms guide

Run user-initiated work that keeps going after the user leaves your app — a large export, an upload, a batch encode — behind one cross-platform API.

Overview

react-native-continued-task is a React Native and Expo library for long-running background tasks that survive the app being backgrounded. It wraps iOS 26's BGContinuedProcessingTask and Android's WorkManager foreground services behind a single typed API, so an upload, export, or batch encode the user started keeps running — with the progress UI each platform draws for it.

It is built on Nitro Modules with Swift on iOS and Kotlin on Android, ships an Expo config plugin, and is written for the constraints these APIs actually impose rather than hiding them.

iOS 26 — Live Activity on the Lock Screen Android — foreground-service notification
iOS Live Activity showing Uploading new animations with a progress bar and a stop button Android notification showing Uploading new animations with a progress bar and a Cancel action

Both show the same task: a title and subtitle that update as it runs, live progress, and a control to cancel it. On iOS that cancel arrives as expired — the system routes it through the same handler as an expiry and gives the app no way to tell them apart. On Android the same tap arrives as user-cancelled.

Features

This is not a general "run some code in the background" library. Both platforms only grant this runtime to work the user just asked for, both show UI they can cancel from, and both kill work that looks stalled. The API is shaped around those constraints rather than hiding them.

Platform Backed by
iOS 26+ BGContinuedProcessingTask, with the system-provided Live Activity
Android (minSdk 24) WorkManager CoroutineWorker running as a foreground service

Installation

npm install react-native-continued-task react-native-nitro-modules

Requires a dev build. Nitro modules never work in Expo Go.

react-native-nitro-modules is an optional peer dependency, so npm and yarn resolve the single copy your app already has instead of nesting a second one. A nested second copy crashes at startup with Nitro was installed twice.

Expo

Add the config plugin and declare the identifier prefixes your app will submit under:

{
  "expo": {
    "plugins": [
      [
        "react-native-continued-task",
        {
          "identifierPrefixes": ["com.foo.MyApp.export"],
          "enableGPU": false
        }
      ]
    ]
  }
}

Then npx expo prebuild. The plugin writes BGTaskSchedulerPermittedIdentifiers on iOS (expanding each prefix to <prefix>.*), the GPU entitlement when enableGPU is set, and on Android the foreground-service permissions plus the merged SystemForegroundService block. See Config plugin for the full option list and the bare-workflow equivalents.

Usage

import {
  ContinuedTasks,
  getSubmitErrorCode,
} from 'react-native-continued-task';

async function exportLibrary(photos: Photo[]) {
  if (!ContinuedTasks.isSupported) return exportInForeground(photos);

  // Must be called from the foreground, in response to a user action.
  const task = await ContinuedTasks.submit({
    identifierPrefix: 'com.foo.MyApp.export',
    title: 'Exporting library',
    subtitle: `0 of ${photos.length} photos`,
    totalUnitCount: photos.length,
  });

  task.addOnStopListener(({ reason, native }) => {
    console.log(`export stopped: ${reason} (${native.domain} ${native.name})`);
    // The task is already gone. Save whatever partial state you have.
  });

  try {
    for (const [index, photo] of photos.entries()) {
      await exportOne(photo);
      // Report progress. This is what keeps the task alive.
      task.setProgress(index + 1, photos.length);
      task.updateTitle(
        'Exporting library',
        `${index + 1} of ${photos.length} photos`
      );
    }
    task.complete(true);
  } catch (error) {
    task.complete(false);
  }
}

API

ContinuedTasks

The entry point. A ContinuedTaskManager.

isSupported: boolean

true on iOS 26+ and on Android once the foreground-service permissions are granted. false on the iOS Simulator, which has no background task scheduler, and on web.

supportsGPU: boolean

Reads BGTaskScheduler.supportedResources — whether this device can grant background GPU access. Always false on Android. Check it before setting ios.requiresGPU; asking for a resource the device cannot provide makes submit reject with not-permitted.

supportsReattach: boolean

true on Android, where a WorkManager worker outlives the app process and attachToTask can hand you a live handle again. false on iOS, where the system cancels continued processing tasks when the app is terminated.

submit(options: ContinuedTaskOptions): Promise<ContinuedTask>

Submits a task and resolves once the platform scheduler accepts it. The returned task starts in the pending state.

Call this from the foreground, in direct response to a user action. iOS requires that submission "occur as a result of a person's action, such as tapping a button"; tasks submitted from a timer, from a push handler, or from the background get cancelled.

Rejects with an Error whose message is prefixed with a stable code. Read it with getSubmitErrorCode(error) instead of matching on the message:

SubmitErrorCode Cause
not-permitted BGTaskScheduler.Error.notPermitted — identifier missing from BGTaskSchedulerPermittedIdentifiers, unentitled or unavailable GPU, or the user denied background launches
too-many-pending-requests .tooManyPendingTaskRequests — cancel pending work and retry
unavailable .unavailable — background refresh off in Settings, or running in the Simulator
immediate-run-ineligible .immediateRunIneligible — only ever with the fail submission strategy
unsupported-platform iOS older than 26, or web
invalid-identifier The prefix is empty, already ends in .*, or is not prefixed with the bundle ID
invalid-options Options failed validation before reaching the platform
foreground-service-unavailable Android could not start the foreground service
unknown Anything else — read the message

The four BGTaskScheduler cases are kept distinct on purpose; they call for different fixes.

ContinuedTaskOptions
Field Type Default Notes
identifierPrefix string — Written without the trailing .*, e.g. com.foo.MyApp.export. Must start with the bundle ID on iOS and be listed in the plugin's identifierPrefixes. The library appends a UUID to build the concrete identifier.
title string — Shown in the Live Activity / notification
subtitle string — Shown under the title
totalUnitCount number — The size of the work, in any unit. Required, because progress is mandatory
ios.submissionStrategy 'queue' | 'fail' 'queue' fail rejects with immediate-run-ineligible rather than waiting
ios.requiresGPU boolean false Needs the GPU entitlement and supportsGPU
android.notificationChannelId string 'continued-task' Created if absent
android.notificationChannelName string 'Background tasks' Only used when creating the channel
android.notificationIcon string app icon Drawable resource name
android.showCancelAction boolean true Wired to WorkManager's createCancelPendingIntent
android.cancelActionLabel string 'Cancel'
android.foregroundServiceType 'dataSync' | 'mediaProcessing' | 'specialUse' 'dataSync' Must match what the plugin declared

getKnownTasks(): Promise<KnownTask[]>

Every task this app has submitted that the library still holds a record for, newest first. Call it on launch — see Reconciling after a silent kill.

interface KnownTask {
  id: string;
  identifierPrefix: string;
  title: string;
  subtitle: string;
  submittedAt: number; // ms since epoch
  state: ContinuedTaskState;
  completedUnitCount: number;
  totalUnitCount: number;
  stopReason?: TaskStopReason;
}

attachToTask(id: string): Promise<ContinuedTask | undefined>

Re-attaches to a task still running natively after the process restarted. Resolves to undefined when there is nothing live — always the case on iOS. Gate on supportsReattach.

forgetTasks(ids: string[]): Promise<void>

Drops persisted records once you have reconciled them. Unknown ids are ignored. Records are never dropped for you, so you decide when reconciliation is done.

ContinuedTask

A live handle to one piece of work. It owns the native task, so there are no ids to thread through your own code and no way to address a task that no longer exists.

Member Notes
id: string '<identifierPrefix>.<uuid>'
title, subtitle: string As currently shown to the user
state: ContinuedTaskState 'pending' | 'running' | 'finished' | 'stopped'
completedUnitCount, totalUnitCount: number Last reported progress
updateTitle(title, subtitle): void Replaces both; iOS has no API for changing one alone
setProgress(completed, total): void Clamped to [0, total]. See below
complete(success): void Idempotent. Not calling it is a bug
cancel(): void Stops with reason app-cancelled
addOnStartListener(cb): ListenerSubscription Fires immediately if already running, so there is no subscribe race
addOnStopListener(cb): ListenerSubscription See Stop reasons

Both listener methods return { remove: () => void }. Removing stops future emissions; it does not un-deliver an event already dispatched to the JS thread.

Progress is load-bearing

"Tasks that do not report any progress will be expired." — Apple

Progress is not decoration on iOS. The scheduler deprioritizes and then kills tasks that look stalled, so setProgress is the call that keeps your task alive. This is why totalUnitCount is a required submit option rather than an optional one: the task starts at a defined 0 / total and the shape of the API pushes you toward reporting as you go.

Stop reasons

addOnStopListener fires when a task stops without complete(). Every event carries a normalized reason and the raw platform detail, because a mapped enum on its own makes these APIs undebuggable on a real device:

interface TaskStopEvent {
  taskId: string;
  reason: TaskStopReason;
  native: {
    domain: string; // 'BGTaskScheduler' | 'WorkManager'
    code?: number; // Android's WorkInfo stop reason; absent for an iOS expiration
    name: string; // e.g. 'STOP_REASON_FOREGROUND_SERVICE_TIMEOUT'
  };
}
TaskStopReason Platform Means
user-cancelled both Cancelled from the iOS Live Activity or the Android notification action
app-cancelled both Your own task.cancel()
expired both System expiration. On iOS this is where a stalled task ends up
fgs-timeout Android 15+ The 6-hour dataSync budget ran out (STOP_REASON_FOREGROUND_SERVICE_TIMEOUT)
quota Android 16+ JobScheduler quota exhausted (STOP_REASON_QUOTA = 10)
app-terminated both Reconstructed on next launch; only ever seen on KnownTask.stopReason
unknown both Read native

iOS cannot tell you the user cancelled

BGContinuedProcessingTask delivers user cancellation and system expiration through the same expirationHandler, which takes no arguments. There is nothing in the shipping SDK that distinguishes them, so this library reports 'expired' with native.name of 'expirationHandler' rather than guessing at `'user-cancell

react-native-continued-task (mahdidavoodi7/react-native-continued-task)

Searching Database…

What is this?A React Native and Expo library that keeps user-initiated background work (exports, uploads, batch encodes) running after the app is backgrounded, wrapping iOS 26's BGContinuedProcessingTask and Android's WorkManager foreground services behind one typed API.
Is this a pure JS package or does it ship native code?It's a native module built with Nitro Modules — Swift on iOS and Kotlin on Android — plus an Expo config plugin that wires the required Info.plist, entitlement and AndroidManifest settings.
How do I add this to an Expo app?Add the config plugin and declare the identifier prefixes your app will submit under, then run `npx expo prebuild`. A custom dev build is required — Nitro modules never work in Expo Go.
Does it need anything besides the package itself?It needs react-native-nitro-modules as an optional peer dependency (resolved to the single copy already in your app), targets iOS 26+ and Android minSdk 24, and only runs inside a dev build, never in Expo Go or the iOS Simulator.
Can it tell when the user actually cancelled a task on iOS?No — on iOS, both user-cancellation and system expiration arrive through the same expirationHandler with no arguments, so the library reports both as 'expired' rather than guessing which one happened. Tasks that stop reporting progress also get killed, since progress updates are mandatory, not cosmetic.
What happens to a task if the app gets swiped away mid-run?Call getKnownTasks() on next launch to reconcile it — that is the only way to detect lost work on iOS, since iOS never reports it otherwise; on Android, attachToTask() can reconnect to the still-running WorkManager worker.
Can I start a task from a push notification or background timer?No. iOS requires submission to happen directly in response to a user action like a button tap — tasks submitted from a timer, push handler, or the background get cancelled.
Has this actually been tested on real hardware?Yes, the author reports it verified 13/13 on a real device running iOS 26.6.1.
Can a task use the GPU in the background?On iOS you can request GPU access via ios.requiresGPU, but only if supportsGPU confirms the device grants it — it's always false on Android, so asking anyway makes submit reject with a not-permitted error.
Size
3.2 MB
Stars
232
Age
1mo
Commits · 90 Days