Skip to content

Instantly share code, notes, and snippets.

@jubishop
Created September 2, 2025 17:12
Show Gist options
  • Select an option

  • Save jubishop/74d5878c26e1e2653790d3d82d95ea4a to your computer and use it in GitHub Desktop.

Select an option

Save jubishop/74d5878c26e1e2653790d3d82d95ea4a to your computer and use it in GitHub Desktop.

For long-running episode downloads, switch CacheManager to schedule URLSession background downloads instead of relying on beginBackgroundTask. A background session lets the system continue transfers while your app is suspended or terminated, and relaunches the app to deliver completion events.

High-level approach

  • Use URLSessionConfiguration.background(withIdentifier:) with a stable identifier.
  • Create a URLSession with a URLSessionDownloadDelegate to receive progress and completion.
  • Start downloads with downloadTask(with:) and return immediately; don’t await the data.
  • In the delegate:
    • didWriteData: report progress
    • didFinishDownloadingTo: move the temp file to your cache directory, update the DB, and notify state
    • didCompleteWithError: handle failures/resume data
    • urlSessionDidFinishEventsForBackgroundURLSession: invoke the OS-provided completion handler
  • In AppDelegate, implement application(_:handleEventsForBackgroundURLSession:completionHandler:) to hold the completionHandler and call it when the session reports it’s done.

Concrete steps adapted to your architecture

  1. Add a background session to DI
  • Use your Container to provide a background session and a long-lived delegate instance. Use a deterministic identifier (e.g., bundleID + “.cache.bg”).
// In your DI container:
extension Container {
  var cacheBackgroundSessionDelegate: Factory<CacheBackgroundDelegate> {
    Factory(self) { CacheBackgroundDelegate() }.scope(.cached)
  }

  var cacheBackgroundSession: Factory<URLSession> {
    Factory(self) {
      let id = Bundle.main.bundleIdentifier.map { "\($0).cache.bg" } ?? "PodHaven.cache.bg"
      let config = URLSessionConfiguration.background(withIdentifier: id)
      config.sessionSendsLaunchEvents = true
      config.allowsCellularAccess = true
      config.waitsForConnectivity = true
      config.isDiscretionary = false // or true if you want iOS to optimize delivery
      config.httpMaximumConnectionsPerHost = 4

      let delegate = self.cacheBackgroundSessionDelegate()
      // Use a serial delegate queue to keep ordering predictable
      return URLSession(configuration: config, delegate: delegate, delegateQueue: OperationQueue())
    }
    .scope(.cached)
  }
}
  1. Implement a URLSessionDownloadDelegate
  • Inject what you need (repo, cacheState, image prefetcher, logger). Move the downloaded temp file to your cache directory and update DB on completion.
final class CacheBackgroundDelegate: NSObject, URLSessionDownloadDelegate {
  @DynamicInjected(\.repo) private var repo
  @DynamicInjected(\.cacheState) private var cacheState // if you have a type for this
  @DynamicInjected(\.sleeper) private var sleeper
  private static let log = Log.as("CacheBackgroundDelegate")

  // Map taskIdentifier -> Episode.ID so we know what we’re handling after relaunch.
  // Persist this mapping (DB or file) whenever you schedule/cancel.
  private let taskMap = TaskMapStore.shared

  func urlSession(_ session: URLSession,
                  downloadTask: URLSessionDownloadTask,
                  didWriteData bytesWritten: Int64,
                  totalBytesWritten: Int64,
                  totalBytesExpectedToWrite: Int64) {
    guard let episodeID = taskMap.episodeID(for: downloadTask.taskIdentifier) else { return }
    let progress = Double(totalBytesWritten) / Double(max(1, totalBytesExpectedToWrite))
    Task { await cacheState.updateProgress(episodeID, progress) }
  }

  func urlSession(_ session: URLSession,
                  downloadTask: URLSessionDownloadTask,
                  didFinishDownloadingTo location: URL) {
    guard let episodeID = taskMap.episodeID(for: downloadTask.taskIdentifier) else { return }
    Task {
      do {
        // Generate filename and move to cache dir
        let episode: Episode? = try await repo.episode(episodeID)
        guard let episode else { throw CacheError.episodeNotFound(episodeID) }

        let fileName = CacheManager.generateCacheFilenameStatic(for: episode) // provide a static helper
        let destURL = CacheManager.resolveCachedFilepath(for: fileName)
        try? FileManager.default.removeItem(at: destURL)
        try FileManager.default.moveItem(at: location, to: destURL)

        try await repo.updateCachedFilename(episodeID, fileName)
        await cacheState.markFinished(episodeID)
      } catch {
        Self.log.error(error)
        await cacheState.markFailed(episodeID, error: error)
      }
      taskMap.remove(downloadTask.taskIdentifier)
    }
  }

  func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    guard let downloadTask = task as? URLSessionDownloadTask else { return }
    guard let episodeID = taskMap.episodeID(for: downloadTask.taskIdentifier) else { return }
    if let error { Task { await cacheState.markFailed(episodeID, error: error) } }
  }

  // Called when the system relaunches the app to deliver all pending delegate calls
  func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    BackgroundURLSessionCompletionCenter.shared.complete(for: session.configuration.identifier)
  }
}
  1. AppDelegate integration for background events
  • Store the completion handler the system gives you and call it when your delegate says it’s done.
class BackgroundURLSessionCompletionCenter {
  static let shared = BackgroundURLSessionCompletionCenter()
  private var completions: [String: () -> Void] = [:]
  func store(identifier: String?, completion: @escaping () -> Void) {
    guard let id = identifier else { return }
    completions[id] = completion
  }
  func complete(for identifier: String?) {
    guard let id = identifier, let completion = completions.removeValue(forKey: id) else { return }
    completion()
  }
}

final class AppDelegate: NSObject, UIApplicationDelegate {
  func application(_ app: UIApplication,
                   handleEventsForBackgroundURLSession identifier: String,
                   completionHandler: @escaping () -> Void) {
    BackgroundURLSessionCompletionCenter.shared.store(identifier: identifier, completion: completionHandler)
  }
}

// In your SwiftUI App:
@main
struct PodHavenApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
  // ...
}
  1. Scheduling downloads in CacheManager
  • Instead of awaiting data, schedule a download task and return immediately. Persist the mapping from taskIdentifier to Episode.ID so you can correlate after relaunch.
// Inside CacheManager
func downloadAndCache(_ podcastEpisode: PodcastEpisode) async throws(CacheError) -> Bool {
  // same early exits (already cached, already downloading)
  let session = await Container.shared.cacheBackgroundSession()
  var request = URLRequest(url: podcastEpisode.episode.media.rawValue)
  request.allowsExpensiveNetworkAccess = true
  request.allowsConstrainedNetworkAccess = true

  let task = session.downloadTask(with: request)
  await cacheState.setDownloadTask(podcastEpisode.id, downloadTaskIdentifier: task.taskIdentifier)
  TaskMapStore.shared.set(taskIdentifier: task.taskIdentifier, episodeID: podcastEpisode.id)

  task.resume()
  return true // scheduled
}
  • On startup, adopt any in-flight tasks and restore mapping:
    • After creating the session, call session.getAllTasks, rebuild your maps, and reattach UI/progress.
  1. Cancel support and resume data
  • When cancelling, prefer cancel(byProducingResumeData:) to allow resuming later. You’ll need to store resume data keyed by episodeID.
  • To resume, create downloadTask(withResumeData:).
  1. Progress and state updates
  • Use didWriteData for progress updates, publish to your cacheState or observatory.
  • Do DB writes and cache moves in your actor contexts (e.g., CacheManager or via injected repo) to keep thread-safety.
  1. Remove beginBackgroundTask for downloads
  • Once using a background session, you don’t need beginBackgroundTask around downloads. The system handles lifecycle and relaunch.

Policy tips

  • Consider config.isDiscretionary = true for energy/cost sensitive behavior (system optimizes transfer timing).
  • If you want Apple Watch or Low Power Mode friendliness, reduce httpMaximumConnectionsPerHost and enable waitsForConnectivity.
  • For very large files, prefer downloadTask so you get a file URL and avoid large memory use.

Testing checklist

  • Start a download, press Home/lock the device; ensure it continues.
  • Kill the app; confirm it relaunches and delegate delivers completion.
  • Verify your AppDelegate completion handler is invoked and cleared.
  • Simulate connectivity drops; ensure resume data is used.

If you want, I can sketch a small TaskMapStore and update your DI scaffolding to fit your Container/FactoryKit style.

Short answer: It depends on your users’ behavior and network reality, but for a podcast app I’d generally recommend using a background URLSession for audio downloads. Even “not that large” episodes routinely exceed the short grace period that beginBackgroundTask provides, especially on cellular or when users background the app right after queuing.

How to decide Use background URLSession if any of these are true:

  • Users often lock the phone or switch apps right after starting a download
  • Median download time > ~15 seconds or p90/p95 > ~30 seconds on typical networks
  • You want downloads to continue across app suspension/termination and resume after relaunch
  • You want the system to optimize power/network usage and reliability

Stick with foreground (your current approach) if all are true:

  • Most episodes reliably complete within ~15–30 seconds on the networks your users use
  • You’re fine with downloads pausing/cancelling when the app backgrounds and resuming only when the user returns
  • You don’t need survival across app termination

Trade-offs

  • Background URLSession
    • Pros: Continues while app is suspended/terminated; resilient to poor networks; OS relaunches your app to deliver completions; less reliance on beginBackgroundTask; standard for media apps.
    • Cons: Adds delegate plumbing and an AppDelegate hook; you need to persist task→episode mapping; changes how you report progress.
  • Foreground session + beginBackgroundTask
    • Pros: Simple; fits your existing actor/DI pattern.
    • Cons: Only a short grace period (~30s, not guaranteed); downloads may be killed mid-transfer; relies on the app staying foregrounded.

Recommended path for PodHaven

  • Use background URLSession specifically for episode audio downloads; keep your current ephemeral session for images/artwork.
  • The integration overhead is reasonable in your DI-driven architecture:
    • Add a background session and delegate in Container
    • Persist taskIdentifier ↔ Episode.ID mapping
    • Handle didFinishDownloadingTo to move the file and update DB
    • Implement application(_:handleEventsForBackgroundURLSession:completionHandler:) to finish background events
  • This yields a user experience that matches expectations for podcast apps: “I tap download, leave the app, and it finishes.”
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment