Introduction

When a URLSession is invalidated (via invalidateAndCancel() or finishTasksAndInvalidate()), all in-flight tasks are cancelled and their completion handlers are called with URLError.cancelled. This is by design, but it causes unexpected failures when the session lifecycle is not properly managed, particularly in view controllers that are deallocated while network requests are still running.

Symptoms

  • URLError.cancelled (-999) for requests that should have succeeded
  • Network requests fail when navigating away from a screen
  • URLSessionTask failed with error: cancelled in async/await code
  • Image downloads fail when table view cells are reused
  • Background tasks are cancelled when app enters background

Example error: `` Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={ NSErrorFailingURLStringKey=https://api.example.com/data, NSLocalizedDescription=cancelled, _NSURLErrorRelatedURLSessionTaskErrorKey=( "LocalDataTask <1234ABCD>.<1>" ) }

Common Causes

  • URLSession stored as a local variable goes out of scope and is deallocated
  • invalidateAndCancel() called too early in the teardown process
  • View controller deallocated while async network request is in flight
  • Using URLSession.shared which can be invalidated by other code
  • Swift concurrency task cancellation propagating to URLSession

Step-by-Step Fix

  1. 1.Maintain a strong reference to the URLSession:
  2. 2.```swift
  3. 3.class APIManager {
  4. 4.// Keep a strong reference - do not use local sessions
  5. 5.private let session: URLSession

init() { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 30 configuration.waitsForConnectivity = true session = URLSession(configuration: configuration) }

func fetchData() async throws -> Data { let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw APIError.invalidResponse } return data } } ```

  1. 1.Handle cancellation gracefully in async code:
  2. 2.```swift
  3. 3.func fetchUserData() async -> User? {
  4. 4.do {
  5. 5.let (data, _) = try await session.data(from: url)
  6. 6.return try? JSONDecoder().decode(User.self, from: data)
  7. 7.} catch is CancellationError {
  8. 8.// Task was cancelled - this is expected, not an error
  9. 9.print("Request cancelled, no action needed")
  10. 10.return nil
  11. 11.} catch {
  12. 12.// Real error
  13. 13.print("Network error: \(error)")
  14. 14.return nil
  15. 15.}
  16. 16.}

// Call with cancellation support: let task = Task { let user = await apiManager.fetchUserData() // Update UI }

// When no longer needed: task.cancel() ```

  1. 1.Use weak self in completion handlers to prevent retain cycles:
  2. 2.```swift
  3. 3.func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
  4. 4.let task = session.dataTask(with: url) { [weak self] data, response, error in
  5. 5.guard let self = self else { return } // Session was deallocated

if let error = error as? URLError, error.code == .cancelled { return // Expected cancellation, not an error }

if let data = data { completion(.success(data)) } else { completion(.failure(error ?? APIError.unknown)) } } task.resume() } ```

  1. 1.Properly invalidate the session during teardown:
  2. 2.```swift
  3. 3.deinit {
  4. 4.// Use finishTasksAndInvalidate to let in-flight tasks complete
  5. 5.session.finishTasksAndInvalidate()
  6. 6.// NOT session.invalidateAndCancel() which kills in-flight requests
  7. 7.}
  8. 8.`

Prevention

  • Use a singleton or DI-provided URLSession instead of creating per-request sessions
  • Handle URLError.cancelled as an expected case, not an error
  • Use Swift concurrency Task cancellation for structured concurrency
  • Avoid URLSession.shared in production code; create your own session
  • Set waitsForConnectivity = true for unreliable networks
  • Use session.getTasksWithCompletionHandler to check for in-flight tasks during teardown