What iOS 26 background tasks actually do

Ankit Singh ·

iOS 26 added BGContinuedProcessingTask: work a person starts in your app keeps running after they leave it, and the system draws its own progress banner with a Stop button. On paper it answers the oldest question in photo, export and sync features: how do I keep going when the user swipes home?

The documentation tells you how to submit one. It doesn’t tell you what gets one suspended, expired early, or labelled “Failed” on the Lock Screen. We found out on real iPhones (TestFlight builds on an iPhone 13) while building a feature that sorts a photo library into trips in the background. In the last post the scan was foreground-only, and moving it off the screen was “planned”. This is what that took.

Every rule below comes with what iOS did and how we saw it. The code that applies them is open source as @aermes/expo-continued-task.

How we could see anything at all

A process that iOS freezes can’t tell you it was frozen, so the native side appends one JSON line per lifecycle step (submitted, launched, progress_first, expired, ended) to a file in Documents that devicectl copies off the phone. While the task runs, a native timer also writes a tick every 10 seconds. Ticks with no progress mean JS stalled; no ticks mean the process was suspended.

That timer had to be strict, on a user-initiated queue: at utility quality of service iOS coalesced ticks to 30 to 100 seconds apart, so a missing tick proved nothing. And it ticks every 10 seconds, not 30, because the gap we measured between the last progress report and an expiry was about 27 seconds. Everything below was read out of those files.

Rule 1: submit only from a tap

Apple’s documentation for BGContinuedProcessingTaskRequest says: “Submission needs to occur as a result of a person’s action, such as tapping a button.” It doesn’t say how that’s enforced, and in practice nothing stops you. A submission from app launch succeeds, and iOS even launches the task.

We did exactly that, to resume an unfinished scan automatically. The task launched, but the process was suspended under it. In one run the work crawled at about one photo per 30 seconds for 23 minutes, and then iOS expired the task. The same job, restarted from a Resume button, ran for 45 minutes fully in the background and sorted 7,928 photos.

So the task is only as good as the tap behind it. The package never submits on its own: start() belongs on a button, with the app in front. A job that wants to pick up after a relaunch parks behind “Tap to resume” instead, and the tap asks for the task.

Rule 2: the progress bar is a contract

The system banner shows your Progress. Apple’s WWDC25 session 227, “Finish tasks in the background”, says that if “progression is slower than expected, the system will prompt the initiator”, and the class documentation says “the system prioritizes the termination of tasks that reflect minimal or no progress”. Neither says what “expected” means.

Our first bar was three fixed bands, one per phase of the job. The second phase streamed in during the first, into a tiny share of the bar. In one run, 61 units finished in the last minute of a phase and moved the bar 0.21 points. Its rate had decayed from 12.5 points a minute to 1.9. At 28 % iOS put this in front of the tester:

Processing is 28% complete. Do you want to continue…?

and expired the task three minutes later.

The fix was to stop banding. Every unit of work now has a cost in milliseconds: a moving average of what it actually took on this phone in the background, seeded with a measured value. Each report advances the bar by that unit’s share of what’s left:

f ← f + (cap − f) · done / (done + remaining)

where done is the cost of the work that just completed and remaining is the current estimate of everything still owed. It never goes backwards, never freezes while work lands, and a total learned late rescales the rest of the bar instead of stalling it. With a sound estimate it moves linearly in time, the only rate iOS can reasonably call expected.

The cap is there because of the second failure. The new bar once reached 1.0 at the end of a phase with 10,420 photos still owed to the next one, showing an ETA of 1 second. iOS expired a task whose bar said it was finished, within about 30 seconds. The package now holds the bar at 0.97 while anything is owed and lets it reach 1 only when nothing is and every size is known.

Rule 3: a memory warning means expiry is coming

Three times out of three, iOS expired the continued task 19 to 43 seconds after the app received a memory-pressure warning. There was no negotiation in between.

Our cause was the photo library read: its malloc footprint grew from 117 MB to 353 MB in 25 seconds, until we fixed how it enumerated the fetch result. The working target we landed on is under about 300 MB in the background, with small units of work so the one in flight when things go wrong is cheap to lose.

It’s also what makes background time unpredictable: the job that got 45 minutes once got 6 minutes at night after a memory warning.

Rule 4: never let the banner say “Failed”

Completing the task with setTaskCompleted(success: false) shows “Failed” in the system UI. That part is documented. What surprised us is that success: true isn’t enough on its own: a run that was stopped on purpose at 588 of 100,000 units, and completed with success: true, still read “Failed”, because the bar was left short under the running phase’s title.

To someone who tapped a button and walked away, “Failed” reads as “your photos are broken”. So no path in the package completes with false. Every ending is one of two things:

The native expiration handler does the same thing. The real cause goes to the log file, not the Lock Screen.

The banner has one more constraint worth knowing: iOS cut our subtitle at about 32 characters (a Lock Screen screenshot from testing read “151 trips · tidying photos 81/8,57…”). We keep every subtitle at 30 characters or fewer and put the phase in the title instead.

Rule 5: say it before the task ends

The obvious order is: complete the task, then notify the person. On device, that order loses.

In one run the job finished away from the screen, setTaskCompleted ran, and 2 seconds later the process was suspended with nothing said. Completing the task can be the last thing a backgrounded process gets to do. A Live Activity isn’t an option either, because one can’t be started from the background.

So the order is reversed. The done or paused notification goes out in a beforeEnd hook, which runs before native hears about the end, while the task still holds the process. The hook is capped at 2 seconds so a hung one can’t keep the task alive until iOS kills it.

That covers endings the app sees. For the ones it doesn’t (iOS freezes JS mid-run, or expires the task and the expiry only reaches JS when the user reopens the app) there’s a dead-man’s switch: while work runs away from the screen, one local notification stays scheduled 45 seconds in the future and is pushed back on every sign of progress. If the progress stops, the OS delivers it without us.

Rule 6: the 30 seconds after leaving are yours only if you ask early

beginBackgroundTask still gives you roughly 30 seconds after a minimise. We use it only to finish the unit already in hand, never to start one. Apple warns that if you call it shortly before suspension, you may be suspended before it’s granted. We saw exactly that: requested late, the process stopped about 1 second after leaving. The native module now takes the grant at willResignActive and hands it back once the unit in flight commits. It’s also the whole fallback on iOS versions without continued tasks.

What it looks like in code

Install it and add the config plugin, which permits your task identifiers and adds the processing background mode:

npx expo install @aermes/expo-continued-task
// app.json
{
  "expo": {
    "plugins": [
      ["@aermes/expo-continued-task", { "taskIdentifierPrefix": "com.yourcompany.yourapp.work" }]
    ]
  }
}

It needs a development build (npx expo prebuild or EAS Build), not Expo Go. Then a long job is one call:

import { AppState } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as bg from '@aermes/expo-continued-task';
import { createContinuedJob } from '@aermes/expo-continued-task';

const exporter = createContinuedJob({
  name: 'export',
  bg,
  appState: AppState,
  size: () => items.length,           // how many units are owed
  step: async () => exportNext(),     // one unit; resolve false when none are left
  seedMs: 500,                        // what one unit costs in the background, measured (ms)
  words: {
    title: 'Exporting photos',
    line: ({ done, total }) => `${done} of ${total}`,
    done: ({ total }) => `${total} exported`,
  },
  notifications: {                    // optional
    Notifications,
    done: { title: 'Export finished', body: 'Your photos are ready' },
    paused: { title: 'Export paused', body: 'Tap to resume' },
  },
});

<Button title="Export" onPress={() => exporter.start()} />   // from a tap, app in front

Your job supplies how many units are owed and a step that does one. The rest is the rules above: a cost-weighted bar under 100 % while work is owed, the unit in hand finishing under the grace grant when iOS stops the task, “Paused” or done and never “Failed”, and the notification before the end. Keep units small: the job checks between units, so a unit is the most work you can lose.

If the work fits in the 30 seconds after the user leaves (a queue of small uploads, a cache flush), you don’t need a continued task, a banner or a tap. Use the grace-only job:

import { createGraceOnlyJob } from '@aermes/expo-continued-task';

const flush = createGraceOnlyJob({ name: 'flush', step: async () => flushOne(), appState: AppState, bg });
flush.start();

It finishes the unit in hand when the app leaves, stops at a boundary, and hands the grant back.

For several phases or your own progress UI, the building blocks are exported too: createContinuedTask, createCostBar, createNotifier, createForegroundBridge.

The overnight tier, which the package doesn’t include

The older BGProcessingTask gives you windows when iOS chooses, usually overnight on a charger. We use it for catch-up nobody is waiting on. It isn’t in the package, but what we measured is worth passing on.

Over five days iOS granted 34 windows, and four did any work. That was mostly our fault:

What’s not solved

iOS decides how long a continued task lives: 45 minutes or 6, and nothing we set controls which. The package makes a long run as likely as we know how, not guaranteed. Design for cut-and-resume: small units, idempotent commits, and a job that can pick up where it parked when the person taps Resume.

One open question: our test builds set NSProgress.estimatedTimeRemaining from the bar’s measured rate, and we don’t yet know whether iOS reads it when judging “slower than expected”.

The package is iOS only (every call is a no-op on Android), and continued tasks need iOS 26 and Xcode 26. On older systems it falls back to the grace grant.

It’s MIT licensed, and it’s the code Aermes ships in its own iOS app:

If you’ve measured something different on another device or iOS build, or hit a way the banner fails that isn’t covered here, open an issue. Pull requests with a device log attached are the most welcome kind.