# Background Transfers and WebSockets

Two things that are often confused. A background URLSession moves HTTP and
HTTPS uploads and downloads while the app is not running. A WebSocket is a
live foreground connection. Neither does the other's job.

## Contents

- [Setting up a background session](#setting-up-a-background-session)
- [Downloads that outlive the app](#downloads-that-outlive-the-app)
- [Relaunch and session event delivery](#relaunch-and-session-event-delivery)
- [Uploads from a file on disk](#uploads-from-a-file-on-disk)
- [URLSessionWebSocketTask](#urlsessionwebsockettask)
- [Reconnecting a dropped socket](#reconnecting-a-dropped-socket)
- [Typed messages over a socket](#typed-messages-over-a-socket)
- [Pitfalls of background sessions](#pitfalls-of-background-sessions)
- [Showing download progress in SwiftUI](#showing-download-progress-in-swiftui)
- [WebSocket Authentication](#websocket-authentication)
- [Choosing a subprotocol](#choosing-a-subprotocol)

## Setting up a background session

A background session hands its transfers to a system process. They carry on
while the app is suspended, after the system terminates it, and across a
device restart; the system also retries network failures by itself. When a
transfer finishes, the app is woken or relaunched to hear about it. Use one
for anything the user expects to complete after leaving the app: syncing a
folder, downloading an episode, uploading a video.

There is one hard limit: a swipe-away in the app switcher. That user
force-quit cancels every pending background transfer, and the system keeps
the app closed, with no wake-ups for those transfers, until the person
launches it by hand.

### Configuration

```swift
final class BackgroundTransferService: NSObject, @unchecked Sendable {
    static let shared = BackgroundTransferService()
    static let identifier = "com.example.reader.transfers"

    @MainActor var systemCompletionHandler: (() -> Void)?

    lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: Self.identifier)
        config.isDiscretionary = false
        config.sessionSendsLaunchEvents = true
        config.allowsExpensiveNetworkAccess = true
        config.allowsConstrainedNetworkAccess = false
        config.timeoutIntervalForResource = 24 * 60 * 60
        let callbackQueue: OperationQueue? = nil
        return URLSession(configuration: config, delegate: self, delegateQueue: callbackQueue)
    }()
}
```

- The identifier is unique per app and prefixed with the bundle ID. After a
  relaunch, a new session built from that identifier picks up the transfers
  that were already running.
- The session needs a delegate. Its events arrive through delegate callbacks,
  possibly in a process that was just relaunched, so async/await calls on it
  are not an option.
- `delegateQueue: nil` gives the session its own serial operation queue.
- `@unchecked Sendable` is acceptable here because `URLSession` is
  thread-safe, the lazy session is first touched on the main thread, and the
  completion handler is main-actor isolated.
- Background sessions are available since iOS 7; the async APIs used around
  them need iOS 15.

### Key Configuration Options

| Property | Effect | Typical choice |
|---|---|---|
| `isDiscretionary` | `true` lets the system wait for power and good network | `true` for optional sync, `false` to start now |
| `sessionSendsLaunchEvents` | Relaunch the app when transfers finish | `true`; completion handling needs it |
| `allowsConstrainedNetworkAccess` | `false` respects Low Data Mode | `false` for optional downloads |
| `allowsExpensiveNetworkAccess` | `false` keeps transfers off cellular and hotspots | `false` for very large files |
| `timeoutIntervalForResource` | Maximum lifetime of a whole transfer | Default is 7 days |

## Downloads that outlive the app

Background downloads are `downloadTask(with:)` tasks. The async overloads
such as `data(for:)` and `download(for:)` are not supported on a background
session.

```swift
extension BackgroundTransferService {
    func enqueueDownload(of source: URL) {
        let job = session.downloadTask(with: source)
        job.earliestBeginDate = .now
        job.countOfBytesClientExpectsToSend = 0
        job.countOfBytesClientExpectsToReceive = 80 * 1_048_576
        job.resume()
    }

    func resumeDownload(with resumeData: Data) {
        session.downloadTask(withResumeData: resumeData).resume()
    }
}
```

The byte estimates help the scheduler decide when to run the task.

### Download Delegate

```swift
extension BackgroundTransferService: URLSessionDownloadDelegate {
    func urlSession(_: URLSession, downloadTask job: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        let fileName = job.originalRequest?.url?.lastPathComponent
        let destination = URL.documentsDirectory.appending(path: fileName ?? UUID().uuidString)
        let files = FileManager.default
        do {
            if files.fileExists(atPath: destination.path()) { try files.removeItem(at: destination) }
            try files.moveItem(at: location, to: destination)
        } catch {
            Logger.transfers.error("Could not keep download: \(error.localizedDescription)")
        }
    }

    func urlSession(_: URLSession, downloadTask job: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        guard totalBytesExpectedToWrite > 0,
              let url = job.originalRequest?.url else { return }
        reportProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite), for: url)
    }

    func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError failure: (any Error)?) {
        guard let error = failure else { return }
        let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data
        if let resumeData, let url = task.originalRequest?.url {
            let file = URL.temporaryDirectory.appending(path: "resume-\(url.absoluteString.hashValue)")
            try? resumeData.write(to: file)
        }
    }
}
```

- `location` exists only until `didFinishDownloadingTo` returns, so the move
  happens inside it.
- Progress arrives on the session queue; UI updates must hop to the main
  actor (see the tracker below).
- `didCompleteWithError` with a `nil` error is the success case, already
  handled when the file was moved.
- On failure, the resume data is in the error's `userInfo` under
  `NSURLSessionDownloadTaskResumeData`. Persist it and pass it to
  `downloadTask(withResumeData:)` later. `hashValue` is seeded per launch, so
  a real app should key the file by a stable ID it stores itself.

## Relaunch and session event delivery

When a transfer finishes while the app is not running, the system relaunches
it in the background and calls the app delegate with a completion handler.
Call that handler once every pending event has been delivered.

```swift
final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        guard identifier == BackgroundTransferService.identifier else { return completionHandler() }
        BackgroundTransferService.shared.systemCompletionHandler = completionHandler
        _ = BackgroundTransferService.shared.session
    }
}

extension BackgroundTransferService {
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        Task { @MainActor in
            self.systemCompletionHandler?()
            self.systemCompletionHandler = nil
        }
    }
}

@main
struct ReaderApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    var body: some Scene {
        WindowGroup { ShelfView() }
    }
}
```

- Touching `session` recreates the background session, which is what makes
  the queued delegate events flow.
- `urlSessionDidFinishEvents(forBackgroundURLSession:)` fires after the last
  pending event.
- Call the stored handler exactly once, on the main thread. It tells the
  system to take the app-switcher snapshot and suspend the app; skipping it
  leaves a stale snapshot and wastes background time.
- SwiftUI apps reach the app delegate through `@UIApplicationDelegateAdaptor`.

## Uploads from a file on disk

A background upload must come from a file. Uploading `Data` or a stream is
not supported on a background session.

```swift
extension BackgroundTransferService {
    func enqueueUpload(of stagedFile: URL, to endpoint: URL,
                       verb: String = "POST", extraHeaders: [String: String] = [:]) {
        var put = URLRequest(url: endpoint)
        put.httpMethod = verb
        put.allHTTPHeaderFields = extraHeaders
        session.uploadTask(with: put, fromFile: stagedFile).resume()
    }

    func urlSession(_: URLSession, task: URLSessionTask, didSendBodyData _: Int64,
                    totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
        guard totalBytesExpectedToSend > 0, let url = task.originalRequest?.url else { return }
        reportProgress(Double(totalBytesSent) / Double(totalBytesExpectedToSend), for: url)
    }
}
```

`uploadTask(with:from:)` with in-memory data fails on a background session.
For a multipart upload, write the encoded body to a temporary file first and
upload that file.

## URLSessionWebSocketTask

`URLSessionWebSocketTask` (iOS 13 and later) is the built-in WebSocket
client. It connects to `ws:` or `wss:` URLs. It is a foreground, default
session tool: a background configuration does not keep a socket alive once
the app is suspended.

### Basic Connection

```swift
final class SocketLink: Sendable {
    private let socket: URLSessionWebSocketTask

    init(address: URL, using session: URLSession) {
        socket = session.webSocketTask(with: address)
    }

    func open() { socket.resume() }

    func close(why: String? = nil) {
        let note = why.map { Data($0.utf8) }
        socket.cancel(with: .normalClosure, reason: note)
    }

    func write(_ line: String) async throws {
        try await socket.send(.string(line))
    }
    func write(_ blob: Data) async throws {
        try await socket.send(.data(blob))
    }
    func read() async throws -> URLSessionWebSocketTask.Message {
        let incoming = try await socket.receive()
        return incoming
    }
}
```

### WebSocket with Structured Concurrency

An actor owns the socket and a receive loop that feeds an `AsyncStream`.

```swift
actor SocketChannel {
    enum Event: Sendable {
        case opened
        case text(String)
        case binary(Data)
        case closed(URLSessionWebSocketTask.CloseCode, Data?)
        case failed(any Error)
    }

    private let url: URL
    private let session: URLSession
    private var socket: URLSessionWebSocketTask?
    private var receiver: Task<Void, Never>?

    init(address: URL, using session: URLSession = .shared) {
        url = address
        self.session = session
    }

    func events() -> AsyncStream<Event> {
        let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
        let socket = session.webSocketTask(with: url)
        self.socket = socket
        socket.resume()
        continuation.yield(.opened)
        receiver = Task {
            while !Task.isCancelled {
                do {
                    switch try await socket.receive() {
                    case .string(let words): continuation.yield(.text(words))
                    case .data(let blob): continuation.yield(.binary(blob))
                    @unknown default: break
                    }
                } catch {
                    if socket.closeCode == .invalid {
                        continuation.yield(.failed(error))
                    } else {
                        continuation.yield(.closed(socket.closeCode, socket.closeReason))
                    }
                    break
                }
            }
            continuation.finish()
        }
        continuation.onTermination = { [weak self] _ in
            Task { await self?.close() }
        }
        return stream
    }

    func send(_ text: String) async throws {
        try await socket?.send(.string(text))
    }

    func close() {
        receiver?.cancel()
        socket?.cancel(with: .normalClosure, reason: nil)
        receiver = nil
        socket = nil
    }

    func keepAlive(every interval: Duration = .seconds(30)) async {
        while !Task.isCancelled {
            do { try await Task.sleep(for: interval) } catch { return }
            socket?.sendPing { error in
                if error != nil {
                    Logger.transfers.notice("Ping failed; the connection is probably gone")
                }
            }
        }
    }
}
```

- A receive error with `closeCode == .invalid` means the peer never sent a
  close frame: treat it as an unexpected failure. Any other close code is an
  orderly close.
- The receive loop captures only the socket and the continuation, never the
  actor, and the termination handler holds the actor weakly, so neither keeps
  the channel alive.
- `keepAlive` sends a ping on an interval; a failed ping usually means the
  connection is dead and a reconnect is due.

### Usage in SwiftUI

```swift
@MainActor @Observable
final class RoomModel {
    enum LinkState { case offline, connecting, online }

    struct Line: Identifiable { let id = UUID(); let text: String; let isMine: Bool }

    private(set) var lines: [Line] = []
    private(set) var state: LinkState = .offline
    private let channel: SocketChannel
    private var listener: Task<Void, Never>?

    init(channel: SocketChannel) { self.channel = channel }

    func connect() async {
        state = .connecting
        let stream = await channel.events()
        listener = Task {
            for await event in stream { handle(event) }
        }
    }

    func post(_ text: String) async {
        do {
            try await channel.send(text)
            lines.append(Line(text: text, isMine: true))
        } catch {
            state = .offline
        }
    }

    func disconnect() async {
        listener?.cancel()
        await channel.close()
        state = .offline
    }

    private func handle(_ event: SocketChannel.Event) {
        switch event {
        case .opened: state = .online
        case .text(let text): lines.append(Line(text: text, isMine: false))
        case .binary(let blob):
            lines.append(Line(text: String(decoding: blob, as: UTF8.self), isMine: false))
        case .closed, .failed: state = .offline
        }
    }
}

struct RoomView: View {
    let model: RoomModel
    var body: some View {
        List(model.lines) { Text($0.text) }
            .task { await model.connect() }
            .onDisappear { Task { await model.disconnect() } }
    }
}
```

A sent message is appended only after `send` succeeds. On `.closed` or
`.failed` the model goes offline; reconnecting is the next section.

## Reconnecting a dropped socket

Sockets drop. A durable client reconnects on its own with exponential
backoff.

```swift
actor ResilientChannel {
    private let url: URL
    private let maxAttempts = 10
    private let firstDelay: Duration = .seconds(1)
    private let ceiling: Duration = .seconds(60)
    private var attempts = 0
    private var stoppedOnPurpose = false
    private var current: SocketChannel?

    init(url: URL) { self.url = url }

    func events() -> AsyncStream<SocketChannel.Event> {
        AsyncStream { continuation in
            let loop = Task {
                while shouldContinue() {
                    let channel = fresh()
                    for await event in await channel.events() {
                        if case .opened = event { resetAttempts() }
                        continuation.yield(event)
                    }
                    if Task.isCancelled || isStopped() { break }
                    let wait = nextDelay()
                    if (try? await Task.sleep(for: wait)) == nil { break }
                }
                continuation.finish()
            }
            continuation.onTermination = { _ in
                loop.cancel()
                Task { await self.stop() }
            }
        }
    }

    func stop() async {
        stoppedOnPurpose = true
        await current?.close()
    }

    private func shouldContinue() -> Bool { !stoppedOnPurpose && attempts < maxAttempts }
    private func isStopped() -> Bool { stoppedOnPurpose }
    private func resetAttempts() { attempts = 0 }

    private func fresh() -> SocketChannel {
        let channel = SocketChannel(address: url)
        current = channel
        return channel
    }

    private func nextDelay() -> Duration {
        attempts += 1
        let grown = firstDelay * Int(pow(2.0, Double(attempts - 1)))
        let capped = min(grown, ceiling)
        return capped + capped * Double.random(in: 0...0.25)
    }
}
```

- Each attempt uses a new channel, and every event is passed through.
- The `Task` made inside `events()` inherits the actor's isolation, so the
  actor's own helpers are called without `await`; only the other actor
  (`channel.events()`) needs one.
- A successful open resets the attempt counter.
- After a stream ends: stop if the disconnect was intentional or the task
  was cancelled; otherwise count the attempt and sleep. A cancelled sleep ends
  the loop.
- Delay is `firstDelay * 2^(attempt - 1)`, capped at 60 seconds, plus up to
  25% jitter; at most 10 attempts.
- Ending the outer stream marks the stop as intentional and closes the live
  channel.

## Typed messages over a socket

Wrap typed messages in an envelope that names the type, so one socket can
carry many message kinds.

```swift
protocol SocketMessage: Codable, Sendable {
    static var kind: String { get }
}

struct Envelope: Codable, Sendable {
    let kind: String
    let payload: Data
}

struct TypedChannel: Sendable {
    enum Incoming: Sendable {
        case opened
        case message(kind: String, payload: Data)
        case closed(URLSessionWebSocketTask.CloseCode)
        case failed(any Error)
    }

    let channel: SocketChannel

    func send<M: SocketMessage>(_ message: M) async throws {
        let envelope = Envelope(kind: M.kind, payload: try JSONEncoder().encode(message))
        let frame = try JSONEncoder().encode(envelope)
        try await channel.send(String(decoding: frame, as: UTF8.self))
    }

    func incoming() async -> AsyncStream<Incoming> {
        let raw = await channel.events()
        return AsyncStream { continuation in
            let pump = Task {
                for await event in raw {
                    switch event {
                    case .opened: continuation.yield(.opened)
                    case .text(let text): yieldEnvelope(Data(text.utf8), to: continuation)
                    case .binary(let data): yieldEnvelope(data, to: continuation)
                    case .closed(let code, _): continuation.yield(.closed(code))
                    case .failed(let problem): continuation.yield(.failed(problem))
                    }
                }
                continuation.finish()
            }
            continuation.onTermination = { _ in pump.cancel() }
        }
    }

    private func yieldEnvelope(_ data: Data, to continuation: AsyncStream<Incoming>.Continuation) {
        guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data) else { return }
        continuation.yield(.message(kind: envelope.kind, payload: envelope.payload))
    }
}
```

Frames that are not valid envelopes are dropped. The receiver switches on
`kind` and decodes `payload` into the matching `SocketMessage` type. The
channel above sends text frames; add a `send(data:)` to it if the server
expects binary envelopes.

## Pitfalls of background sessions

- Session identifiers must be unique within the app. Two sessions sharing one
  can deliver events to the wrong delegate. Prefix with the bundle ID.
- `data(for:)` and `download(for:)` are not available on a background
  session.
- Only download and upload tasks run there. Data tasks do not; turn a data
  request into a download if it must survive.
- WebSocket tasks are not background transfers. Reconnect when the app
  becomes active again.
- Write task identifiers and planned destinations to disk. Memory does not
  survive a background relaunch.
- A user force-quit is not a system termination: transfers are cancelled and
  the app is not relaunched for them.
- Move or open the `location` file inside `didFinishDownloadingTo`.
- The app delegate's background-session callback hands over a completion
  closure. Hold on to it, then invoke it a single time on the main thread
  when `urlSessionDidFinishEvents(forBackgroundURLSession:)` arrives.
- Background scheduling in Simulator does not match a device. Test on
  hardware.

## Showing download progress in SwiftUI

Delegate callbacks run on the session queue. Bridge them into an observable
model on the main actor:

```swift
@MainActor @Observable
final class TransferBoard {
    struct Entry {
        enum Phase { case running, done, failed }
        var fraction: Double = 0
        var phase: Phase = .running
    }

    static let shared = TransferBoard()
    private(set) var entries: [URL: Entry] = [:]

    func update(_ url: URL, fraction: Double) { entries[url, default: Entry()].fraction = fraction }
    func finish(_ url: URL) { entries[url] = Entry(fraction: 1, phase: .done) }
    func fail(_ url: URL) { entries[url, default: Entry()].phase = .failed }
}

extension BackgroundTransferService {
    func reportProgress(_ fraction: Double, for url: URL) {
        Task { @MainActor in TransferBoard.shared.update(url, fraction: fraction) }
    }
}
```

## WebSocket Authentication

Authenticate during the opening handshake, with either a query parameter or
a header:

```swift
func socketUsingQuery(_ base: URL, token: String, session: URLSession) -> URLSessionWebSocketTask? {
    guard var parts = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil }
    parts.queryItems = [URLQueryItem(name: "access_token", value: token)]
    guard let url = parts.url else { return nil }
    return session.webSocketTask(with: url)
}

func socketUsingHeader(_ url: URL, token: String, session: URLSession) -> URLSessionWebSocketTask {
    var handshake = URLRequest(url: url)
    handshake.allHTTPHeaderFields = ["Authorization": "Bearer " + token]
    return session.webSocketTask(with: handshake)
}
```

Prefer the header. Query strings tend to end up in server and proxy access
logs.

## Choosing a subprotocol

```swift
let socket = session.webSocketTask(with: endpoint, protocols: ["graphql-transport-ws"])

final class SocketObserver: NSObject, URLSessionWebSocketDelegate {
    func urlSession(_: URLSession, webSocketTask socket: URLSessionWebSocketTask,
                    didOpenWithProtocol chosen: String?) {
        if chosen != "graphql-transport-ws" { socket.cancel(with: .protocolError, reason: nil) }
    }

    func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask,
                    didCloseWith code: URLSessionWebSocketTask.CloseCode, reason _: Data?) {
        Logger.transfers.info("Socket closed with code \(code.rawValue)")
    }
}
```

Check the protocol the server actually chose in `didOpenWithProtocol`. Close
events report a `CloseCode` and optional reason data.

The snippets on this page log through a shared logger:

```swift
import OSLog
extension Logger { static let transfers = Logger(subsystem: "app.networking", category: "transfers") }
```
