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: cancelledin 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
URLSessionstored as a local variable goes out of scope and is deallocatedinvalidateAndCancel()called too early in the teardown process- View controller deallocated while async network request is in flight
- Using
URLSession.sharedwhich can be invalidated by other code - Swift concurrency task cancellation propagating to URLSession
Step-by-Step Fix
- 1.Maintain a strong reference to the URLSession:
- 2.```swift
- 3.class APIManager {
- 4.// Keep a strong reference - do not use local sessions
- 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.Handle cancellation gracefully in async code:
- 2.```swift
- 3.func fetchUserData() async -> User? {
- 4.do {
- 5.let (data, _) = try await session.data(from: url)
- 6.return try? JSONDecoder().decode(User.self, from: data)
- 7.} catch is CancellationError {
- 8.// Task was cancelled - this is expected, not an error
- 9.print("Request cancelled, no action needed")
- 10.return nil
- 11.} catch {
- 12.// Real error
- 13.print("Network error: \(error)")
- 14.return nil
- 15.}
- 16.}
// Call with cancellation support: let task = Task { let user = await apiManager.fetchUserData() // Update UI }
// When no longer needed: task.cancel() ```
- 1.Use weak self in completion handlers to prevent retain cycles:
- 2.```swift
- 3.func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
- 4.let task = session.dataTask(with: url) { [weak self] data, response, error in
- 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.Properly invalidate the session during teardown:
- 2.```swift
- 3.deinit {
- 4.// Use finishTasksAndInvalidate to let in-flight tasks complete
- 5.session.finishTasksAndInvalidate()
- 6.// NOT session.invalidateAndCancel() which kills in-flight requests
- 7.}
- 8.
`
Prevention
- Use a singleton or DI-provided
URLSessioninstead of creating per-request sessions - Handle
URLError.cancelledas an expected case, not an error - Use Swift concurrency
Taskcancellation for structured concurrency - Avoid
URLSession.sharedin production code; create your own session - Set
waitsForConnectivity = truefor unreliable networks - Use
session.getTasksWithCompletionHandlerto check for in-flight tasks during teardown