Keeping Meeting Apps Alive in the Background on iOS and watchOS

Building an app that records meetings, generates transcriptions, and produces AI analysation sounds straightforward – until you try to make it reliable while the user goes in and out of your app, takes calls, plays music, or checks notifications.

This post walks through how we handled background recording and post‑recording processing on iOS and watchOS in a real-world project. We’ll cover:

  • Recovering from audio interruptions on iPhone and Apple Watch
  • Differences between iOS and watchOS audio behavior
  • Using BGContinuedProcessingTask (iOS 26+) to run heavy processing in the background
  • Practical patterns and pitfalls around progress reporting and heartbeats

All examples are distilled from our production code, slightly simplified for readability.


1. The Problem: Long‑Running Recording + Processing in the Real World

Our app’s main flow:

  1. Record a meeting (on iPhone or Apple Watch)
  2. Transcribe the audio using transcription services
  3. Analyze using AI

Key requirements:

  • Recording must survive common real-world interruptions (audio from other apps, system events, incoming calls, etc.).
  • If a recording is interrupted, we should resume automatically where possible.
  • After the user stops recording, transcription and AI processing should continue in the background, even if the user leaves the app or locks the device.

This led us to two major areas:

  • Recording robustness (handling interruptions)
  • Background processing (transcription + AI pipeline)

2. Background-Safe Recording on iOS

On iOS we used AVAudioRecorder inside a custom MeetingAudioRecorder class that:

  • Handles interruptions via AVAudioSession.interruptionNotification
  • Uses mixWithOthers to avoid unnecessary interruptions from other audio apps

2.1. Configuring AVAudioSession with mixWithOthers

The key session setup (simplified):

private func setupAVAudioSession() throws {
 let session = AVAudioSession.sharedInstance()
 try session.setCategory(
  .playAndRecord,
  mode: .default,
  options: [.mixWithOthers]
 )
 try session.setActive(true)
}

Why mixWithOthers?

  • By default, other apps playing audio (e.g. YouTube, Spotify) will interrupt your recording.
  • With .mixWithOthers, our app can record while other apps play audio, and the OS only interrupts us for hard interruptions (e.g. incoming calls, alarms).
  • Crucially, this also made it possible for us to reliably resume recording after a call ended, even when our app was in the background, because our session category remained compatible with the system’s policies.

2.2. Handling Interruptions on iOS

We subscribe to AVAudioSession.interruptionNotification and pause/attempt resume:

@objc private func handleInterruption(_ notification: Notification) {
 guard let userInfo = notification.userInfo,
  let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
  let type = AVAudioSession.InterruptionType(rawValue: typeValue)
 else {
  return
 }
 switch type {
 case .began:
  isInterrupted = true
  recorder.pause()
 case .ended:
  guard isRecording else {
   isInterrupted = false
   return
  }
  recorder.record()
  isInterrupted = false
 @unknown default:
  break
 }
}

This works well on iOS thanks to the combination of:

  • AVAudioSessionCategory.playAndRecord + .mixWithOthers
  • Foreground or background; as long as the OS lets us reclaim audio, record() succeeds.

3. Why WatchOS Is Different

On watchOS, the same trick with mixWithOthers is not sufficient.

Even if we mirror the session category semantics, when an interruption happens and the app is in the background, the system is much more aggressive about reclaiming audio. After an interruption ends, your app often cannot simply resume recording in the background.

Because of that, our watch flow is different:

  • We still use robust interruption handling.
  • But after an interruption that ends while we’re in background, we cannot trust automatic resume.
  • Instead, we schedule a notification asking the user to reopen the app, and resume only once the app is back in foreground.
  • Keep in mind it might still make sense to use the same configuration on watch (AVAudioSessionCategory.playAndRecord + .mixWithOthers), so we only get interrupted on calls and alamrs.

3.1. Interruption Handling on Watch

We still listen to AVAudioSession.interruptionNotification:

@objc private func handleInterruption(_ notification: Notification) {
 guard let userInfo = notification.userInfo,
  let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
  let type = AVAudioSession.InterruptionType(rawValue: typeValue)
 else {
  return
 }
 switch type {
 case .began:
  isInterrupted = true
  recorder.pause()
 case .ended:
  if WKExtension.shared().applicationState == .active {
   resumeRecording()
  } else {
   pendingResume = true
   createResumeNotification()
  }
 @unknown default:
  break
 }
}

The critical difference from iOS:

  • If the app is active (foreground), we attempt to auto‑resume.
  • If the app is in background, we do not try to resume immediately. Instead, we:
    • Set pendingResume = true
    • Schedule a notification asking the user to reopen the app

This is because in practice, trying to resume from background on watchOS after an interruption is unreliable. The system is more likely to deny the resume or kill the app.

If you want to avoid using mixWithOthers configuration for recordings, you can use the same approach for iPhones (sending notification and waiting for the user to reopen the app).

3.3. “Please Reopen the App” Notification

We schedule a notification to bring the user back:

private func createResumeNotification() {
 WKInterfaceDevice.current().play(.notification)
 let content = UNMutableNotificationContent()
 content.title = "Recording paused"
 content.body = "Open the app to resume your recording"
 content.sound = .default
 if #available(watchOS 8.0, *) {
 content.interruptionLevel = .timeSensitive
 }
 let request = UNNotificationRequest(
  identifier: "resume_recording",
  content: content,
  trigger: nil
 )
 UNUserNotificationCenter.current().add(request)
}

When the app becomes active again (after the user taps the notification), we complete the resume with listening for WKExtension.applicationDidBecomeActiveNotification event:

@objc private func handleAppActive() {
 guard isRecording, pendingResume else { return }
 pendingResume = false
 let center = UNUserNotificationCenter.current()
 center.removePendingNotificationRequests(withIdentifiers: ["resume_recording"])
 center.removeDeliveredNotifications(withIdentifiers: ["resume_recording"])
 resumeRecording()
}

Practical watchOS limitation:
In real testing we also observed that notification delivery on watch can be delayed, so we needed to account for the fact that the user may only get the “resume” notification after a handful amount of time. That’s not something you can fully fix in code; it’s part of the system’s heuristics.


4. Background Work with BGContinuedProcessingTask (iOS 26+)

Once a recording is stopped, we kick off a processing pipeline.

  • Transcription
  • AI analysation

We wanted this pipeline to continue even if the user leaves the app by putting it in the background or locking the device, so we used the new BGContinuedProcessingTask available in iOS 26.

4.1. Native Module Wrapper

We create an Expo native module, ContinuedProcessingTaskModule, to:

  • Register and submit BGContinuedProcessingTaskRequest
  • Bridge progress and completion to JS
  • Handle cancellation and system expiration

Starting a task from JS calls into:

private static var registeredIdentifiers: Set<String> = []
...
AsyncFunction("startTask") { (identifier: String, title: String, subtitle: String) -> [String: Any] in
 if #available(iOS 26.0, *) {
  if !Self.registeredIdentifiers.contains(identifier) {
   try BGTaskScheduler.shared.register(
    forTaskWithIdentifier: identifier,
    using: nil
   ) { [weak self] task in
    guard let continuedTask = task as? BGContinuedProcessingTask else {
     task.setTaskCompleted(success: false)
    }
    self?.handleContinuedProcessingTask(continuedTask)
   }
   Self.registeredIdentifiers.insert(identifier)
  }
  return try self.startTask(
   identifier: identifier,
   title: title,
   subtitle: subtitle
  )
 } else {
  throw NSError(
   domain: "ContinuedProcessingTask",
   code: 1,
   userInfo: [
    NSLocalizedDescriptionKey: "BGContinuedProcessingTask requires iOS 26+"
   ]
  )
 }
}

Submitting the actual task:

@available(iOS 26.0, *)
private func startTask(
 identifier: String,
 title: String,
 subtitle: String
) throws -> [String: Any] {
 let request = BGContinuedProcessingTaskRequest(
  identifier: identifier,
  title: title,
  subtitle: subtitle
 )
 request.strategy = .queue
 try BGTaskScheduler.shared.submit(request)
 return [
  "started": true,
  "identifier": identifier,
 ]
}

When the OS launches our task in the background:

@available(iOS 26.0, *)
private func handleContinuedProcessingTask(_ task: BGContinuedProcessingTask) {
 taskPool[task.identifier] = task
 task.expirationHandler = { [weak self] task in
  BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: task.identifier)
  task.setTaskCompleted(success: false)
  self?.sendEvent(
   "onTaskCompleted",
   [
    "identifier": task.identifier,
    "success": false,
   ])
 }
 sendEvent(
  "onTaskRequested",
  ["identifier": task.identifier]
 )
}

Limitations you must keep in mind:

  • Task may be terminated for network connection, low battery, or high memory usage reasons.
  • The OS expects steady progress updates; if it looks “stale,” it will terminate the task.
  • You cannot assume infinite time; you need to be resilient to early cancellation.

4.2. Reporting Progress Safely (Monotonic Progress)

From JS we call a ContinuedProcessingTask base class that wraps progress reporting:

protected updateProgress(
 completedUnit: number,
): void {
 if (completedUnit > this.completedUnit) {
  this.completedUnit = completedUnit;
 }
 const totalUnitCount = ContinuedProcessingTask.PROGRESS_TOTAL_UNIT_COUNT;
 ContinuedProcessingTaskModule.updateProgress(
  this.identifier,
  totalUnitCount,
  completedUnit,
 );
}

On the native side, this maps to the BG task’s progress:

Function("updateProgress") {
 (identifier: String, totalUnitCount: Int, completedUnit: Int) in
 if #available(iOS 26.0, *) {
  if let task = taskPool[identifier] as? BGContinuedProcessingTask {
   task.progress.totalUnitCount = Int64(totalUnitCount)
   task.progress.completedUnit = Int64(completedUnit)
  }
 }
}

Why “monotonic” progress matters

We explicitly never decrease progress:

  • The OS expects progress to move forward.
  • Regressing progress or updating too infrequently increased the risk of tasks being shut down.
  • Internally we use a large unit count (10_000) so small increments are still visible to the OS.

5. Heartbeats: Convincing iOS the Task Is Still Alive

Even if your processing loop is busy, if you don’t update progress or call any BGContinuedProcessingTask APIs for a while, the OS may treat the task as idle and kill it.

To avoid this, we introduced a heartbeat interval in the ContinuedProcessingTask base class:

private static readonly HEARTBEAT_INTERVAL_MS = 20_000; // 20 seconds
private static readonly PROGRESS_TOTAL_UNIT_COUNT = 10_000;
private _startHeartbeat(): void {
 this._stopHeartbeat();
 this.heartbeatInterval = setInterval(() => {
  this.completedUnit = this.completedUnit + 1;
  const totalUnitCount = ContinuedProcessingTask.PROGRESS_TOTAL_UNIT_COUNT;
  ContinuedProcessingTaskModule.updateProgress(
   this.identifier,
   totalUnitCount,
   this.completedUnit,
  );
 }, ContinuedProcessingTask.HEARTBEAT_INTERVAL_MS);
}
private _stopHeartbeat(): void {
 if (this.heartbeatInterval !== null) {
  clearInterval(this.heartbeatInterval);
  this.heartbeatInterval = null;
 }
}

We start this interval when the native module tells us processing has started (onTaskRequested), and stop it on completion.

This tiny periodic increment:

  • Satisfies the “only increasing progress” rule.
  • Acts as a heartbeat, signaling to iOS that our task is still processing.
  • Helps prevent the OS from killing the task as “stale,” especially during CPU‑bound or network‑waiting phases where we don’t naturally emit progress.

Once the BGContinuedProcessingTask is started, it will keep your app alive and previously starter processes will keep running just like in foreground as long as the BGContinuedProcessingTask is not terminated by the system or manually.


Takeaways and Practical Tips

  • On iOS, using AVAudioSession with .mixWithOthers can dramatically improve your app’s ability to record through non-critical audio and recover after calls, even from the background.
  • On watchOS, you should assume background resume is fragile after interruptions. Plan for a user re‑entry flow:
    • Mark that a resume is pending
    • Schedule a time‑sensitive notification
    • Resume only when the app is active again
  • For long-running background processing on iOS 26+:
    • Use BGContinuedProcessingTask to get foreground-like execution in the background.
    • Always report monotonic progress; never decrease it.
    • Implement a heartbeat that nudges progress periodically so the OS doesn’t treat your task as idle.
    • Be prepared for early termination due to system constraints; save intermediate results frequently.

Designing for background reliability across iOS and watchOS is less about a single magic API and more about respecting each platform’s constraints, leaning on the right capabilities (session categories, BG tasks), and layering in robust recovery paths for the many ways the system can interrupt your app.

Need help?

If you find yourself running up against weird edge cases like audio cutting out or simply dealing with the headaches of getting something to run reliably in the background – then you know how quickly things can get complicated. Throw in cross-platform differences between iOS and watchOS, and it’s like you’re trying to hit a moving target.

At RisingStack, we’ve been helping teams hammer out and ship mobile systems that can actually deal with the kind of real-world stuff that happens, rather than just some idealised test scenario.

We can lend a hand with stuff like:

  • Figuring out how to architecture background processing that actually makes sense
  • Debugging those super tricky lifecycle issues that seem to have a life of their own
  • Getting your long-running tasks performing like they should be
  • Or even just general mobile development that adds up to more than just a bunch of disconnected pieces

The idea is to get in where it really matters.

👉 Get in touch with RisingStack and let’s see if we can make something reliable for you!

Share this post

Twitter
Facebook
LinkedIn
Reddit

Related posts

ChatGPT Live and the New Architecture of Voice AI

OpenAI has introduced GPT-Live, a new generation of voice models that now powers ChatGPT Voice. At first, this may sound like another voice-quality update. The voices have been remastered, ChatGPT should interrupt less often, and it can respond more naturally

Read More »

Node.js
Experts

Learn more at risingstack.com

Node.js Experts