Skip to main content

2 posts tagged with "URLSession"

View All Tags

Automating the Networking Layer in iOS: Implementing Swift OpenAPI Generator at Scale

Published: · 14 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Modern iOS teams waste countless hours hand-writing networking code: request builders, response decoding, error mapping, auth headers, pagination helpers, retries - the list never ends. It’s brittle, hard to keep in sync with backend changes, and risky to refactor at scale.

This post is a practical, production-focused swift openapi generator tutorial. We’ll walk through how to use Swift OpenAPI Generator to generate a type-safe networking layer in Swift, integrate it with your app’s architecture, and roll it out across large iOS codebases. We’ll cover the end-to-end flow - OpenAPI spec to generated client, dependency injection, MVVM integration, testing, error handling, retries, and CI - so you can confidently adopt it at scale.

What you’ll build​

  • A dedicated SPM module that owns your generated API client
  • A URLSession-based transport with auth, logging, and retry middleware
  • A repository layer that maps OpenAPI DTOs into your domain models
  • A SwiftUI view model calling the generated client with async/await
  • A testable setup using a mock transport and fixtures
  • A scalable rollout plan with CI verification and spec drift detection

Prerequisites​

  • Xcode 15.4+ (or Xcode 16+) with Swift 5.9+
  • iOS 16+ (targets earlier than iOS 16 are possible but require more conditional availability)
  • An OpenAPI 3.0/3.1 spec provided by your backend team
  • Packages: https://github.com/apple/swift-openapi-generator (provides the generator plugin, OpenAPIRuntime, and OpenAPIURLSession)

Why Swift OpenAPI Generator for type-safe networking in Swift?​

  • Strongly typed requests and responses: Compile-time types for parameters, bodies, and success/error responses.
  • Drift resistance: If the backend changes the API spec, your build breaks where your client code is inconsistent. You fix it once.
  • Less boilerplate: You no longer write URLRequest setup, encoders/decoders, or status code branching by hand.
  • Extensible runtime: URLSession transport, middleware for auth/logging/retries, and test transports for mocks.
  • Scales with teams: A single source of truth (the spec) generates consistent, predictable client code across features and modules.

Architecture overview (modularized)​

  • App (iOS target): SwiftUI/UIKit views + feature view models
  • NetworkingAPI (SPM target): Generated client + lightweight custom glue
    • Generated code: By Swift OpenAPI Generator (not checked into source)
    • Runtime: OpenAPIRuntime + OpenAPIURLSession for URLSession transport
    • Middlewares: Auth, logging, retry
  • Domain/Repositories (SPM or local modules): Protocols and mapping from API DTOs to domain models
  • Tests: Unit tests use a mock transport to simulate responses

This separation keeps your generated code isolated, easily swappable, and testable.

Step 1: Add the package and plugin to your project​

Add the package to your workspace (File > Add Packages… in Xcode) or edit Package.swift:

// Package.swift (excerpt)
let package = Package(
name: "YourWorkspace",
platforms: [.iOS(.v16)],
products: [
.library(name: "NetworkingAPI", targets: ["NetworkingAPI"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.3.0"),
],
targets: [
.target(
name: "NetworkingAPI",
dependencies: [
.product(name: "OpenAPIRuntime", package: "swift-openapi-generator"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-generator"),
],
plugins: [
// This is the SwiftPM build plugin Xcode runs at build time.
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
]
),
.testTarget(
name: "NetworkingAPITests",
dependencies: ["NetworkingAPI"]
),
]
)

The plugin will run on each build to generate sources for NetworkingAPI.

Step 2: Provide your OpenAPI document​

By default, the plugin looks for an openapi.yaml or openapi.json file inside the target directory. Create this file at Sources/NetworkingAPI/openapi.yaml.

Example (trimmed) OpenAPI snippet:

openapi: 3.0.3
info:
title: Example API
version: "1.0.0"
servers:
- url: https://api.example.com
paths:
/users:
get:
operationId: listUsers
parameters:
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100 }
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
/users/{id}:
get:
operationId: getUser
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
required: [id, name]
properties:
id: { type: string, format: uuid }
name: { type: string }
email: { type: string, format: email }

Tip:

  • Set operationId for stable, readable Swift method names.
  • Document error responses (e.g., 400/401/403/404/429/5xx) so they become typed enum cases.

If you need custom generation options (e.g., public access), add a config file (e.g., openapi-generator-config.yaml) beside your spec. Xcode will detect it automatically. Refer to the project’s README for exact keys and defaults.

Step 3: Build and explore the generated client​

Build once. The plugin generates a Client type and request/response models in the build directory. In your NetworkingAPI module, wire up a URLSession transport:

// Sources/NetworkingAPI/APIClient.swift

import Foundation
import OpenAPIRuntime
import OpenAPIURLSession

// Holds baseline dependencies for the generated Client
public struct APIEnvironment {
public var serverURL: URL
public var session: URLSession
public var middlewares: [any ClientMiddleware]

public init(
serverURL: URL,
session: URLSession = .shared,
middlewares: [any ClientMiddleware] = []
) {
self.serverURL = serverURL
self.session = session
self.middlewares = middlewares
}
}

// Factory to build a configured Client from the generated code.
public enum APIClientFactory {
public static func makeClient(env: APIEnvironment) -> Client {
let transport = URLSessionTransport(configuration: env.session.configuration, session: env.session)
return Client(
serverURL: env.serverURL,
transport: transport,
middlewares: env.middlewares
)
}
}

Notes:

  • Client is generated. Its exact module and symbol names depend on your target and spec. Use Xcode autocomplete to confirm available operations (e.g., listUsers, getUser).
  • OpenAPIURLSession.URLSessionTransport integrates URLSession with the generator’s runtime.

Step 4: Middlewares: auth, logging, and retries​

Use runtime middleware to attach headers, log requests, and retry transient failures. This keeps concerns out of feature code.

// Sources/NetworkingAPI/Middlewares.swift

import Foundation
import OpenAPIRuntime

// Injects Authorization: Bearer <token> header if available.
public struct BearerAuthMiddleware: ClientMiddleware {
private let tokenProvider: () -> String?

public init(tokenProvider: @escaping () -> String?) {
self.tokenProvider = tokenProvider
}

public func intercept(
_ request: inout Request,
baseURL: URL,
operationID: String,
next: (inout Request, URL) async throws -> Response
) async throws -> Response {
if let token = tokenProvider() {
request.headerFields[.authorization] = "Bearer \(token)"
}
return try await next(&request, baseURL)
}
}

// Simple request/response logger (sanitize PII in production).
public struct LoggingMiddleware: ClientMiddleware {
public init() {}
public func intercept(
_ request: inout Request,
baseURL: URL,
operationID: String,
next: (inout Request, URL) async throws -> Response
) async throws -> Response {
#if DEBUG
print("➡️ [\(operationID)] \(request.method.rawValue) \(baseURL)\(request.path)")
#endif
let response = try await next(&request, baseURL)
#if DEBUG
print("⬅️ [\(operationID)] \(response.statusCode)")
#endif
return response
}
}

// Exponential backoff with jitter for 429/5xx.
public struct RetryMiddleware: ClientMiddleware {
public init() {}

public func intercept(
_ request: inout Request,
baseURL: URL,
operationID: String,
next: (inout Request, URL) async throws -> Response
) async throws -> Response {
var attempt = 0
let maxAttempts = 3

while true {
do {
let response = try await next(&request, baseURL)
if shouldRetry(status: response.statusCode), attempt < maxAttempts - 1 {
attempt += 1
try await Task.sleep(nanoseconds: backoff(attempt))
continue
}
return response
} catch {
// Retry for network-layer transient errors if desired.
if attempt < maxAttempts - 1, isTransient(error) {
attempt += 1
try await Task.sleep(nanoseconds: backoff(attempt))
continue
}
throw error
}
}
}

private func shouldRetry(status: Int) -> Bool {
status == 429 || (500...599).contains(status)
}

private func isTransient(_ error: Error) -> Bool {
// Expand with URL error codes as needed.
(error as? URLError)?.code == .timedOut
}

private func backoff(_ attempt: Int) -> UInt64 {
let base: Double = 0.3
let max: Double = 2.0
let delay = min(max, pow(2.0, Double(attempt)) * base)
let jitter = Double.random(in: 0...(delay * 0.2))
return UInt64((delay + jitter) * 1_000_000_000)
}
}

Compose them when building the client:

// Example composition (e.g., in AppDelegate/DI container)
let env = APIEnvironment(
serverURL: URL(string: "https://api.example.com")!,
session: {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
return URLSession(configuration: config, delegate: nil, delegateQueue: nil)
}(),
middlewares: [
BearerAuthMiddleware { AuthStore.shared.token },
LoggingMiddleware(),
RetryMiddleware()
]
)
let apiClient = APIClientFactory.makeClient(env: env)

Security tip: If you need certificate pinning, provide a URLSession with a custom delegate and store minimal PII in logs.

Step 5: A repository that shields your app from generated types​

In production, keep generated types out of your domain. Introduce a repository protocol to abstract the generated client and map DTOs to domain models.

// Sources/NetworkingAPI/UsersRepository.swift

import Foundation

public struct User: Equatable, Identifiable {
public let id: UUID
public let name: String
public let email: String?
}

public enum UsersRepositoryError: Error {
case notFound
case unauthorized
case server
case decoding
case transport(Error)
}

public protocol UsersRepository {
func list(limit: Int?) async throws -> [User]
func get(id: UUID) async throws -> User
}

Implementation using the generated client:

// Sources/NetworkingAPI/UsersRepositoryImpl.swift

import Foundation

public final class UsersRepositoryImpl: UsersRepository {
private let client: Client

public init(client: Client) {
self.client = client
}

public func list(limit: Int?) async throws -> [User] {
// The generator creates functions based on operationId.
// Adjust names to your generated API.
let response = try await client.listUsers(.init(query: .init(limit: limit)))
switch response {
case .ok(let ok):
// Access the typed body based on content type; typically .json.
return try ok.body.json.map(mapUser)
case .undocumented(let status, _):
throw mapStatus(status)
default:
throw UsersRepositoryError.server
}
}

public func get(id: UUID) async throws -> User {
let response = try await client.getUser(.init(path: .init(id: id.uuidString)))
switch response {
case .ok(let ok):
return try mapUser(ok.body.json)
case .undocumented(let status, _):
throw mapStatus(status)
default:
throw UsersRepositoryError.server
}
}

private func mapUser(_ dto: Components.Schemas.User) throws -> User {
guard let uuid = UUID(uuidString: dto.id) else { throw UsersRepositoryError.decoding }
return User(id: uuid, name: dto.name, email: dto.email)
}

private func mapStatus(_ status: Int) -> UsersRepositoryError {
switch status {
case 401: return .unauthorized
case 404: return .notFound
case 500...599: return .server
default: return .server
}
}
}

Key points:

  • The generator returns typed “response enums” for each operation (cases per status code, plus .undocumented for unexpected ones).
  • Map DTOs to domain early and keep generated types inside the networking module.

Step 6: Use in SwiftUI with async/await​

// App/Features/Users/UsersViewModel.swift

import Foundation
import Observation // or @MainActor with ObservableObject if preferred

@Observable
final class UsersViewModel {
private let repo: UsersRepository
private var loadTask: Task<Void, Never>?

// UI state
var users: [User] = []
var isLoading = false
var errorMessage: String?

init(repo: UsersRepository) {
self.repo = repo
}

@MainActor
func load(limit: Int? = 20) {
loadTask?.cancel()
isLoading = true
errorMessage = nil

loadTask = Task { [weak self] in
guard let self else { return }
do {
let result = try await repo.list(limit: limit)
await MainActor.run {
self.users = result
self.isLoading = false
}
} catch {
await MainActor.run {
self.errorMessage = Self.map(error)
self.isLoading = false
}
}
}
}

func cancel() {
loadTask?.cancel()
}

private static func map(_ error: Error) -> String {
switch error {
case UsersRepositoryError.unauthorized:
return "Please sign in again."
case UsersRepositoryError.notFound:
return "Not found."
default:
return "Something went wrong."
}
}
}

Wire up in your SwiftUI view:

// App/Features/Users/UsersView.swift

import SwiftUI

struct UsersView: View {
@State private var model: UsersViewModel

init(repo: UsersRepository) {
_model = State(initialValue: UsersViewModel(repo: repo))
}

var body: some View {
List(model.users) { user in
VStack(alignment: .leading) {
Text(user.name).font(.headline)
if let email = user.email {
Text(email).font(.subheadline).foregroundStyle(.secondary)
}
}
}
.overlay {
if model.isLoading { ProgressView() }
}
.task { model.load() }
.refreshable { model.load() }
.alert("Error", isPresented: .constant(model.errorMessage != nil)) {
Button("OK") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "")
}
}
}

Testing with a mock transport (no network required)​

You can fully test repositories by swapping out the transport, without touching URLSession. Implement a ClientTransport that returns canned responses.

// Tests/NetworkingAPITests/MockTransport.swift

import Foundation
import OpenAPIRuntime

struct MockTransport: ClientTransport {
let handler: (Request, URL) throws -> Response

func send(_ request: Request, baseURL: URL) async throws -> Response {
// In real tests, support async and fixtures from disk.
try handler(request, baseURL)
}
}

Build a Client that uses MockTransport, then test the repository:

// Tests/NetworkingAPITests/UsersRepositoryTests.swift

import XCTest
@testable import NetworkingAPI
import OpenAPIRuntime

final class UsersRepositoryTests: XCTestCase {
func testListUsers_ok() async throws {
let mock = MockTransport { request, _ in
// Validate request method/path if you want.
var response = Response(statusCode: 200)
// Encode a JSON array that matches the generated schema.
let body = try JSONEncoder().encode([
["id": UUID().uuidString, "name": "Alice", "email": "a@example.com"],
])
response.body = .init(body, contentType: "application/json")
return response
}

let client = Client(
serverURL: URL(string: "https://example.test")!,
transport: mock,
middlewares: []
)
let repo = UsersRepositoryImpl(client: client)

let users = try await repo.list(limit: 10)
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users.first?.name, "Alice")
}
}

This approach gives you deterministic, fast tests without spinning URLSession.

Scaling the approach in a large codebase​

  • Modularize by API domain:
    • Create one SPM target per backend service (e.g., PaymentsAPI, CatalogAPI, AuthAPI).
    • Each has its own openapi.yaml and Client, keeping compile and generation scopes small.
  • Keep generated code internal:
    • Do not re-export generated types across modules; keep a repository facade and domain models per feature.
  • Build performance:
    • The plugin runs at build time; incremental builds are generally fast if the spec is stable.
    • If the spec changes infrequently, consider building a small “API generation” scheme on CI to validate before developers update locally.
  • Continuous Integration:
    • Run regular builds to catch spec drift.
    • Add a job that lints the OpenAPI (e.g., with Spectral) before merging.
    • If you serve the spec from a URL, gate merges on fetching and validating the latest version.
  • Backward compatibility:
    • If your backend produces breaking changes, version the spec (e.g., v1, v2).
    • Run two modules concurrently during migration; deprecate and remove once client code completes.

Production-readiness checklist​

  • Auth
    • Use middleware for bearer tokens; refresh tokens on 401 if applicable.
    • Avoid leaking tokens in logs.
  • Security
    • TLS by default with URLSessionTransport.
    • Implement SSL pinning via URLSessionDelegate if mandated.
  • Errors
    • Model typed error responses in the spec; map them to domain errors once (in repositories).
    • Handle .undocumented responses to avoid silent failures on new status codes.
  • Retries
    • Retry idempotent requests on 429/5xx with exponential backoff and jitter.
    • Don’t retry non-idempotent writes unless designed.
  • Timeouts
    • Set reasonable request/resource timeouts per feature (e.g., search vs. background sync).
  • Observability
    • Add request IDs and correlation headers if your backend supports them.
    • Log with levels and sanitize PII.
  • Performance
    • Prefer async/await over Combine for request concurrency.
    • Reuse URLSession; tune caching and request coalescing where it helps.
  • App Store
    • Avoid using private APIs; generated code uses public Apple frameworks.
    • Scrub any verbose logging in Release builds.

Common pitfalls and troubleshooting​

  • The plugin doesn’t run or no generated code appears:
    • Ensure the target includes .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator").
    • Make sure your openapi.yaml/openapi.json lives in the target’s source directory (or configure the plugin with a config file).
    • Clean build (Xcode: Product > Clean Build Folder).
  • “No such module OpenAPIRuntime”:
    • Add .product(name: "OpenAPIRuntime", package: "swift-openapi-generator") and .product(name: "OpenAPIURLSession", package: "swift-openapi-generator") to target dependencies.
  • Invalid or unsupported OpenAPI:
    • Validate with a linter (Spectral). Fix schema issues early (e.g., missing content, incorrect schema).
  • Decoding errors:
    • Mismatched Content-Type or schema. Ensure backend returns application/json if that’s what the spec says.
    • Watch out for anyOf/oneOf - model them carefully and test representative payloads.
  • Operation names don’t match expectations:
    • Add or fix operationId to stabilize function names.
  • Auth not applied:
    • Verify middleware order and that tokens are available when building requests.
    • If using OpenAPI security schemes, ensure the spec accurately declares them.

Frequently Asked Questions About Swift OpenAPI Generator​

  • Is this an “Xcode plugin”?
    • No, it is a Swift Package Manager (SPM) build plugin managed by Xcode automatically during builds.
  • Should I commit generated sources?
    • No. The build plugin generates code into your build directory dynamically. Only commit your openapi.yaml spec and config files.
  • Can I use Combine instead of async/await?
    • Yes. While async/await is native to the generated client, you can wrap async calls in custom Future or AnyPublisher wrappers if your app relies on Combine.
  • How do I manage multi-environment configurations (Dev, Staging, Prod)?
    • Pass a dynamic serverURL into your APIEnvironment struct at runtime based on your build targets or launch flags.

Conclusion​

This swift openapi generator tutorial showed how to generate a robust, type safe networking swift layer from an OpenAPI spec, integrate it into a modular iOS architecture, and scale it across teams and features. By relying on the swift-openapi-generator xcode plugin (SwiftPM build plugin), you can generate networking layer swift code that stays in sync with your backend, reduces boilerplate, and catches API drift at compile time.

Next steps:

  • Add your project’s real openapi.yaml.
  • Stand up a NetworkingAPI module with URLSession transport, auth, logging, and retries.
  • Wrap the generated client behind repositories and map DTOs to domain models.
  • Add CI validation for the spec and a few high-signal integration tests.

Once in place, you’ll spend less time on plumbing and more time on product - exactly what “openapi client swift ios” adoption should deliver.

Key resources:

Resolving Background URLSession Stalls on iOS

Published: · 12 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Background downloads are supposed to “just work” after you hand them off to the system. Yet plenty of teams end up with URLSession background download not working in production: transfers stall for hours, handleEventsForBackgroundURLSession not called, errors carry NSURLErrorBackgroundTaskCancelledReasonKey, or nsurlsessiond spikes CPU and drains battery. This guide explains how background URLSession actually runs on iOS, why it stalls, and how to implement a robust, production-ready solution using modern Swift, SwiftUI, and Apple-recommended patterns.

Prerequisites

  • iOS 15+ (tested on iOS 17+), Xcode 15+, Swift 5.9+
  • App uses the modern app lifecycle (SwiftUI) or UIKit, with an AppDelegate available for background session events
  • Background Modes capability enabled in the target (see Setup)

What actually runs your background downloads​

When you create a background URLSession (URLSessionConfiguration.background(withIdentifier:)), iOS hands the work to a system daemon, nsurlsessiond. That daemon:

  • Performs the transfer even if your app is suspended or terminated
  • Relaunches your app in the background when events are ready (e.g., a download finished)
  • Calls AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:) to give your process a short window to handle callbacks
  • Expects you to recreate a URLSession with the same identifier and the same delegate to receive completion events
  • Kills your process if you don’t call the provided completionHandler within the allowed time

Stalls happen when any of these expectations are violated or when network, policy, or resource limits prevent progress.

Symptoms and root causes​

1) Troubleshooting: “URLSession background download not working”​

Common root causes:

  • You didn’t recreate the background session with the same identifier on next launch
  • You never implemented or wired up AppDelegate.handleEventsForBackgroundURLSession
  • Your delegate was deallocated or not retained
  • You created multiple background sessions with different identifiers and lost track of tasks
  • You’re doing long-running work in delegate callbacks and exceeding the background time limit
  • Constrained network policies (Low Data Mode, expensive or constrained networks) block progress because of configuration flags

2) handleEventsForBackgroundURLSession not called​

  • SwiftUI-only apps forget to provide an AppDelegate. With scenes, this must still be implemented on the application delegate, not in a scene delegate.
  • You didn’t recreate URLSession(configuration: backgroundID, delegate: …) before the system tries to deliver events.
  • You failed to keep the completion handler to call later after urlSessionDidFinishEvents(forBackgroundURLSession:).

3) NSURLErrorBackgroundTaskCancelledReasonKey appears in error.userInfo​

If a background transfer completes with error code NSURLErrorCancelled (-999), the userInfo may include NSURLErrorBackgroundTaskCancelledReasonKey indicating why the system canceled the task. Typical reasons include:

  • User force-quit the app
  • Background updates disabled (system policy)
  • Insufficient system resources (memory, disk, power)

Inspect and log this key to understand cancellations and adjust behavior (e.g., retry strategy, deferring large work, asking the user to reopen the app).

4) iOS background download task time limit​

  • The system gives your app a limited time window (typically tens of seconds) to handle callbacks after being relaunched for background events.
  • Don’t do heavy decompression, parsing, or database work in download delegates. Move/rename the file and return. Schedule BGProcessingTask for heavy post-processing.
  • If you exceed the limit, the system kills your process and may stop delivering further events, which looks like “stalled” downloads.

5) nsurlsessiond high CPU​

  • Flooding the daemon with too many concurrent tasks, rapid-fire retries, or thrashing resume data can send CPU through the roof.
  • Multiple background sessions per app compete with each other and raise overhead. Use one shared background session.
  • Misconfigured policies causing repeated failures (e.g., trying to download on a constrained or expensive network when disallowed) can cause retry loops.

Setup checklist (current best practices)​

  • Enable “Background Modes” capability. For background transfers, enable Background fetch. This ensures the system treats your app as capable of completing work after relaunch.
  • Use exactly one background session across your app, with a stable identifier.
  • Use URLSessionDownloadTask for large files. Move the file quickly in didFinishDownloadingTo and return.
  • Keep the delegate alive for the entire process lifetime.
  • Recreate the session early on launch (App start), before events arrive.
  • In SwiftUI apps, bridge an AppDelegate via @UIApplicationDelegateAdaptor to receive handleEventsForBackgroundURLSession.
  • Limit concurrency. Let the system schedule (isDiscretionary = true) for large background tasks.

A production-ready background download implementation (Swift, iOS 15+)​

This reference shows:

  • A singleton BackgroundSessionManager owning a single background URLSession
  • Proper AppDelegate wiring for handleEventsForBackgroundURLSession and urlSessionDidFinishEvents
  • Minimal file handling in the delegate
  • Async event stream for progress
  • A sanity pass that reconciles tasks on launch with getAllTasks
  • Hooks for BGProcessingTask to offload heavy post-processing

BackgroundSessionManager​

import Foundation
import OSLog

final class BackgroundSessionManager: NSObject {
static let shared = BackgroundSessionManager()

// Use a reverse-DNS, stable identifier. Never change this after release.
private let identifier = "com.example.yourapp.backgroundtransfer"

// Publish simple progress updates (replace with Combine or your own bus as needed)
struct DownloadProgress {
let url: URL
let bytesWritten: Int64
let totalBytesWritten: Int64
let totalBytesExpected: Int64
}

// Simple callback hooks (swap for Combine/AsyncStream as desired)
var onProgress: ((DownloadProgress) -> Void)?
var onFinished: ((URL, URL) -> Void)? // (remoteURL, fileLocation)
var onError: ((URL, Error) -> Void)?

// The completion handler passed from AppDelegate when events are delivered
private var backgroundEventsCompletionHandler: (() -> Void)?

private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: identifier)
// Let the system optimally schedule background work
config.isDiscretionary = true
// Helpful on spotty networks; the daemon waits for a better connection rather than instantly failing
config.waitsForConnectivity = true
// Respect cellular and Low Data Mode by default; adjust if your app’s UX requires otherwise
config.allowsCellularAccess = true
config.allowsExpensiveNetworkAccess = true
config.allowsConstrainedNetworkAccess = false

// Keep connections reasonable; background daemon manages concurrency too
config.httpMaximumConnectionsPerHost = 2

// Delegate queue: nil => a serial operation queue created by the system
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

private override init() {
super.init()
}

// Called early on app launch to ensure the background session exists before events arrive.
func configure() {
// Reconciliation builds local state for existing tasks after relaunch.
reconcileExistingTasks()
}

// Start a new download. In production, track the task in persistent storage (Core Data/SQLite).
@discardableResult
func startDownload(from url: URL) -> URLSessionDownloadTask {
let task = session.downloadTask(with: url)
task.earliestBeginDate = nil // or set if you want to schedule in the future
task.resume()
return task
}

// Used by AppDelegate when iOS relaunches the app to deliver events
func setBackgroundEventsCompletionHandler(_ handler: @escaping () -> Void) {
backgroundEventsCompletionHandler = handler
}

private func reconcileExistingTasks() {
session.getAllTasks { tasks in
// Useful after relaunch or crash: tasks continue in the daemon;
// rebuild UI state, attach observers, etc.
if tasks.isEmpty { return }
os_log("Reconciled %d existing background tasks", log: .default, type: .info, tasks.count)
}
}

// Call this when you're done handling all background URLSession events.
private func finishEventsIfPossible() {
// If there are no pending delegate callbacks, call the AppDelegate completion handler.
session.getAllTasks { [weak self] tasks in
guard let self = self else { return }
let hasRunning = tasks.contains { $0.state == .running }
if !hasRunning, let handler = self.backgroundEventsCompletionHandler {
self.backgroundEventsCompletionHandler = nil
handler()
}
}
}
}

extension BackgroundSessionManager: URLSessionDownloadDelegate, URLSessionTaskDelegate {
// Progress updates
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
guard let sourceURL = downloadTask.originalRequest?.url else { return }
onProgress?(DownloadProgress(
url: sourceURL,
bytesWritten: bytesWritten,
totalBytesWritten: totalBytesWritten,
totalBytesExpected: totalBytesExpectedToWrite
))
}

// File finished downloading to a temporary location. Move it quickly and return.
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
guard let sourceURL = downloadTask.originalRequest?.url else { return }
do {
let destination = try self.makeDestinationURL(for: sourceURL)
// Remove existing file if present
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: location, to: destination)

// Don’t back up to iCloud; large media can blow user’s quota
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try? destination.setResourceValues(resourceValues)

onFinished?(sourceURL, destination)
} catch {
onError?(sourceURL, error)
}
}

// Final completion (success or failure)
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
guard let sourceURL = task.originalRequest?.url else { return }
if let error = error as NSError? {
// Decode background cancellation reason if present
if error.code == NSURLErrorCancelled,
let reason = error.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? NSNumber {
let message = Self.describeBackgroundCancelReason(reason.intValue)
os_log("Background task for %{public}@ cancelled: %{public}@", "\(sourceURL)", message)
}
onError?(sourceURL, error)
}
// After all delegate callbacks finish and no tasks are running, tell the system we’re done
finishEventsIfPossible()
}

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// Called when the daemon delivered all pending delegate calls for this session.
// We might still have running tasks; finishEventsIfPossible() handles that.
finishEventsIfPossible()
}

private static func describeBackgroundCancelReason(_ value: Int) -> String {
// Public docs expose the key; numeric values are implicitly defined by Apple.
// Here are commonly observed reasons:
switch value {
case 1: return "User force-quit the app"
case 2: return "Background updates disabled"
case 3: return "Insufficient system resources"
default: return "Unknown reason (\(value))"
}
}

private func makeDestinationURL(for sourceURL: URL) throws -> URL {
let caches = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let downloadsDir = caches.appendingPathComponent("BackgroundDownloads", isDirectory: true)
try FileManager.default.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
let filename = sourceURL.lastPathComponent.isEmpty ? UUID().uuidString : sourceURL.lastPathComponent
return downloadsDir.appendingPathComponent(filename)
}
}

AppDelegate wiring (UIKit or SwiftUI lifecycle)​

The single most common reason handleEventsForBackgroundURLSession not called is not having an AppDelegate method at all, especially in a SwiftUI app.

import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
BackgroundSessionManager.shared.configure()
return true
}

// Crucial for background transfers
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// Ensure you recreate the session with the same identifier
BackgroundSessionManager.shared.setBackgroundEventsCompletionHandler(completionHandler)
// Accessing shared will initialize the session if needed; configure() already did this on launch
}
}

SwiftUI entry point:

import SwiftUI

@main
struct YourApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

SwiftUI usage example​

import SwiftUI

struct ContentView: View {
@State private var status: String = "Idle"
private let manager = BackgroundSessionManager.shared

var body: some View {
VStack(spacing: 24) {
Text(status).font(.caption)

Button("Download Sample File") {
let url = URL(string: "https://speed.hetzner.de/100MB.bin")!
manager.startDownload(from: url)
}
}
.padding()
.onAppear {
manager.onProgress = { progress in
let pct = Double(progress.totalBytesWritten) / Double(progress.totalBytesExpected) * 100.0
status = String(format: "Downloading %.1f%%", pct)
}
manager.onFinished = { _, fileURL in
status = "Finished at \(fileURL.lastPathComponent)"
}
manager.onError = { _, error in
status = "Error: \(error.localizedDescription)"
}
}
}
}

Offload heavy post-processing with BGProcessingTask​

For decompression, parsing, or database writes that exceed the iOS background download task time limit, schedule a BGProcessingTask. This avoids getting killed while handling URLSession callbacks.

import BackgroundTasks

enum BackgroundWork {
static let identifier = "com.example.yourapp.heavyprocessing"

static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
handle(task: task as! BGProcessingTask)
}
}

static func schedule() {
let request = BGProcessingTaskRequest(identifier: identifier)
request.requiresNetworkConnectivity = false
request.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(request)
}

private static func handle(task: BGProcessingTask) {
// Do your heavy file/database work here
task.expirationHandler = {
// Clean up if time is about to expire
}
// Call task.setTaskCompleted(success:) when finished
// ...
task.setTaskCompleted(success: true)
}
}

Call BackgroundWork.register() early (e.g., in AppDelegate.didFinishLaunching). After finishing a download in the URLSession delegate, schedule BackgroundWork.schedule() for any large file processing.

Configuration guidance and trade-offs​

  • Use one background session per app. Multiple background sessions multiply the system overhead and complicate event routing.
  • config.isDiscretionary = true lets iOS optimize transfers (e.g., defer large downloads to Wi‑Fi, while the device is charging).
  • waitsForConnectivity = true reduces spurious failures when network is temporarily unavailable.
  • allowsExpensiveNetworkAccess and allowsConstrainedNetworkAccess should reflect your UX:
    • If users expect downloads on cellular and in Low Data Mode, explicitly set them to true.
    • Otherwise, keep constrained access off to respect user policies.
  • httpMaximumConnectionsPerHost: A low number prevents saturating the daemon and helps avoid nsurlsessiond high cpu.

Debugging and testing background transfers​

  • Validate handleEventsForBackgroundURLSession wiring

    • Confirm the AppDelegate method is present and fires by logging on entry.
    • Ensure you recreated the URLSession with the exact same identifier before events are delivered.
    • Keep and call the provided completionHandler only after urlSessionDidFinishEvents(forBackgroundURLSession:) indicates you’re done.
  • Inspect errors and userInfo

    • When you see NSURLErrorCancelled (-999), check error.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey].
    • Log reasons and correlate with user behavior (force-quit), device policy (background refresh disabled), or resource pressure.
  • Observe nsurlsessiond behavior

    • Use Console.app and filter for your bundle ID and “nsurlsessiond”.
    • Instruments: use “Network” and “Energy Log” to spot retry loops, excessive wakeups, and CPU spikes.
  • Simulate challenging networks

    • Use Xcode’s “Network Link Conditioner” (or scheme-based Network Conditions) to inject latency, packet loss, and bandwidth constraints.
    • Toggle Low Data Mode on the device and verify your allowsConstrainedNetworkAccess behavior.
  • Kill and relaunch testing

    • Start a download, force-quit the app, and confirm it finishes and relaunches your app to deliver events.
    • If events don’t arrive, check the identifier mismatch and AppDelegate wiring.
  • Reconcile tasks on launch

    • Always call session.getAllTasks on startup and rebuild UI state; background tasks keep running even if your app was terminated.

Performance and reliability best practices​

  • Keep delegate work minimal:

    • In didFinishDownloadingTo, move/rename the file and return immediately.
    • For large post-processing, schedule BGProcessingTask to avoid hitting the iOS background download task time limit.
  • Avoid retry storms:

    • Back off with exponential delays on transient HTTP failures.
    • Don’t auto-retry NSURLErrorCancelled when NSURLErrorBackgroundTaskCancelledReasonKey indicates user or policy-driven cancellation.
  • Manage concurrency:

    • Limit parallel background downloads. 2–3 concurrent downloads is often sufficient; queue the rest.
    • Let the system optimize with isDiscretionary for large, non-urgent transfers.
  • Storage hygiene:

    • Write to Caches, not Documents, for re-downloadable content.
    • Mark large files as excludedFromBackup to respect iCloud quotas.
    • Handle low disk space errors and clean old downloads.
  • Security:

    • Respect App Transport Security; use HTTPS.
    • Consider certificate pinning for sensitive content.

Troubleshooting checklist​

  • “URLSession background download not working”

    • One background session with a stable identifier
    • Recreate the session on every launch before events arrive
    • AppDelegate implements application(_:handleEventsForBackgroundURLSession:completionHandler:)
    • Keep and call the completion handler after urlSessionDidFinishEvents
    • Limit delegate work; offload heavy tasks to BGProcessingTask
  • handleEventsForBackgroundURLSession not called

    • Using SwiftUI? Add @UIApplicationDelegateAdaptor and implement the method on AppDelegate
    • Identifier mismatch or multiple identifiers in different builds/sandbox targets
    • Delegate not retained or session not created early enough
  • NSURLErrorBackgroundTaskCancelledReasonKey

    • Inspect and log reason to understand policy/user/system cancellations
    • Adjust retry and scheduling accordingly
  • iOS background download task time limit

    • Keep callback work small; schedule BGProcessingTask for heavy lifting
    • Ensure you eventually call the AppDelegate completion handler
  • nsurlsessiond high cpu

    • Reduce parallel tasks and combined background sessions
    • Fix retry loops and honor network constraints
    • Use isDiscretionary and waitsForConnectivity to avoid wasteful wakeups

Key takeaways​

  • Background URLSession is reliable when you follow the contract: one stable background session, correct AppDelegate wiring, minimal delegate work, and timely completion.
  • Inspect NSURLErrorBackgroundTaskCancelledReasonKey to understand cancellations and inform retries.
  • Respect network constraints and user policies to prevent stalls and nsurlsessiond high cpu.
  • Offload heavy processing with BGProcessingTask to stay within the iOS background download task time limit.
  • If you’re still seeing URLSession background download not working, start with the wiring and identifier checks, then move on to policies (discretionary, constrained/expensive), concurrency, and retry behavior.

Next steps

  • Integrate the BackgroundSessionManager skeleton into your app and wire AppDelegate events.
  • Add persistent tracking (Core Data) for tasks and robust retry/backoff logic.
  • Instrument with Console and Instruments to validate behavior under real-world network conditions.