Networking
URLSession networking patterns for building robust API clients and handling data transfer in iOS
You are an expert in networking and URLSession for building iOS applications with Swift. ## Key Points - **Use an `actor` for your API client** to serialize token refresh and prevent duplicate auth requests. - **Model errors explicitly** with a typed `NetworkError` enum so callers can handle specific failure modes (no connection, auth expired, server error). - **Configure `JSONDecoder` once** with date/key strategies and reuse it. Creating a decoder per request is wasteful. - **Set timeouts on `URLSessionConfiguration`.** The default 60-second timeout is too long for most API calls. Use 15-30 seconds for API requests. - **Use `URLSession.shared` for simple requests** and custom sessions only when you need delegate callbacks (progress, background transfers, certificate pinning). - **Respect cancellation.** URLSession tasks support cancellation natively. Cancelling a `Task` that is awaiting `URLSession.data(for:)` cancels the underlying network request. - **Use `URLCache` and HTTP caching headers** for read-heavy endpoints instead of building a custom cache. - **Not checking the HTTP status code.** `URLSession` does not throw on 4xx/5xx responses. Always verify the status code before decoding. - **Decoding on the main thread.** `JSONDecoder.decode` for large payloads blocks the main thread. Perform decoding in a background context or use `Task.detached` for heavy parsing. - **Ignoring `URLSession` delegate retain cycles.** A `URLSession` strongly retains its delegate. Invalidate the session when done (`finishTasksAndInvalidate()`). - **Hardcoding base URLs.** Use build configurations or environment-based injection so staging, QA, and production URLs are separate. - **Forgetting background session rules.** Background `URLSession` uploads/downloads must use file-based transfer (not data tasks). The system relaunches your app to deliver results.
skilldb get ios-swift-skills/NetworkingFull skill: 359 linesNetworking — iOS/Swift
You are an expert in networking and URLSession for building iOS applications with Swift.
Core Philosophy
A well-designed networking layer is a thin, focused boundary between your app and the outside world. Its job is to translate between HTTP (URLs, status codes, JSON bytes) and your domain types (models, errors, results). Everything above this boundary should work in terms of domain types; everything below should be pure HTTP. When this separation is clean, you can swap HTTP libraries, add caching, or change serialization formats without any code above the boundary noticing.
Errors in networking are not exceptional -- they are expected. Network requests fail routinely due to connectivity issues, server outages, authentication expiry, and timeouts. A production networking layer models these failure modes explicitly with a typed error enum rather than relying on generic Error or raw status codes propagating up to the UI. Each error case should carry enough information for the caller to decide what to do: retry, show a message, redirect to login, or fail silently.
URLSession is deliberately minimal. It gives you a raw (Data, URLResponse) tuple and trusts you to interpret it. This means you must always check the HTTP status code yourself -- URLSession does not throw on 4xx or 5xx responses. Building a robust API client means wrapping this tuple-checking, JSON decoding, and error mapping into a reusable layer so that every call site does not repeat the same boilerplate.
Anti-Patterns
-
Not checking the HTTP status code: URLSession succeeds as long as it receives a response, even if that response is a 500 Internal Server Error. Attempting to decode a 4xx or 5xx response body as your expected model type produces confusing decoding errors instead of a clear "server returned an error" message.
-
Creating a new JSONDecoder for every request:
JSONDecoderconfiguration (date strategy, key decoding strategy) should be set once and reused. Creating a fresh decoder per request wastes allocations and risks inconsistent configuration across calls. -
Hardcoding base URLs throughout the codebase: Scattering
"https://api.example.com"across multiple files makes it impossible to switch between staging, QA, and production environments. Inject the base URL through configuration so environment switching is a single change. -
Ignoring cancellation in async/await contexts: When a user navigates away from a screen, any in-flight requests should be cancelled to save bandwidth and prevent stale data from updating the UI. Using
.task {}in SwiftUI handles this automatically, but manually createdTaskblocks require explicit cancellation management. -
Performing JSON decoding on the main thread for large payloads: While small responses decode instantly, a 2MB JSON payload decoded on the main thread causes visible UI hitches. For large responses, decode in a
Task.detachedor on a background context to keep the main thread responsive.
Overview
URLSession is Foundation's networking API for HTTP/HTTPS requests, file downloads/uploads, and WebSocket connections. Combined with Swift Concurrency, Codable, and modern error handling, it enables clean, testable networking layers. This skill covers building production-grade API clients, handling authentication, uploading and downloading, and structuring a networking layer.
Core Concepts
Basic Requests with async/await
// Simple GET
func fetchData<T: Decodable>(from url: URL, as type: T.Type) async throws -> T {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw NetworkError.httpError(statusCode: http.statusCode, data: data)
}
return try JSONDecoder().decode(T.self, from: data)
}
// POST with body
func createItem(_ item: NewItem) async throws -> Item {
var request = URLRequest(url: URL(string: "https://api.example.com/items")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(item)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode(Item.self, from: data)
}
Endpoint Abstraction
A clean pattern for defining API endpoints:
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
struct Endpoint {
let path: String
let method: HTTPMethod
let queryItems: [URLQueryItem]?
let body: Encodable?
let headers: [String: String]
init(
path: String,
method: HTTPMethod = .get,
queryItems: [URLQueryItem]? = nil,
body: Encodable? = nil,
headers: [String: String] = [:]
) {
self.path = path
self.method = method
self.queryItems = queryItems
self.body = body
self.headers = headers
}
func urlRequest(baseURL: URL) throws -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)!
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Accept")
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
if let body {
request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
}
Error Modeling
enum NetworkError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int, data: Data)
case decodingError(DecodingError)
case noConnection
case timeout
case cancelled
var errorDescription: String? {
switch self {
case .invalidResponse: return "Invalid server response"
case .httpError(let code, _): return "HTTP error \(code)"
case .decodingError(let error): return "Decoding failed: \(error.localizedDescription)"
case .noConnection: return "No internet connection"
case .timeout: return "Request timed out"
case .cancelled: return "Request was cancelled"
}
}
static func from(_ error: Error) -> NetworkError {
if let networkError = error as? NetworkError { return networkError }
if let decodingError = error as? DecodingError { return .decodingError(decodingError) }
let nsError = error as NSError
switch nsError.code {
case NSURLErrorNotConnectedToInternet: return .noConnection
case NSURLErrorTimedOut: return .timeout
case NSURLErrorCancelled: return .cancelled
default: return .invalidResponse
}
}
}
Implementation Patterns
Production API Client
actor APIClient {
private let baseURL: URL
private let session: URLSession
private let decoder: JSONDecoder
private let tokenProvider: TokenProvider
init(
baseURL: URL,
session: URLSession = .shared,
decoder: JSONDecoder = .iso8601Decoder(),
tokenProvider: TokenProvider
) {
self.baseURL = baseURL
self.session = session
self.decoder = decoder
self.tokenProvider = tokenProvider
}
func send<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = try endpoint.urlRequest(baseURL: baseURL)
// Attach auth token
let token = try await tokenProvider.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
switch http.statusCode {
case 200..<300:
return try decoder.decode(T.self, from: data)
case 401:
// Token expired — refresh and retry once
try await tokenProvider.refresh()
return try await send(endpoint)
default:
throw NetworkError.httpError(statusCode: http.statusCode, data: data)
}
}
func sendVoid(_ endpoint: Endpoint) async throws {
var request = try endpoint.urlRequest(baseURL: baseURL)
let token = try await tokenProvider.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw NetworkError.invalidResponse
}
}
}
Download with Progress
class DownloadManager: NSObject, ObservableObject {
@Published var progress: Double = 0
@Published var isDownloading = false
private var downloadTask: URLSessionDownloadTask?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
return URLSession(configuration: config, delegate: self, delegateQueue: .main)
}()
func download(from url: URL, to destination: URL) async throws -> URL {
isDownloading = true
defer { isDownloading = false }
return try await withCheckedThrowingContinuation { continuation in
let task = session.downloadTask(with: url) { tempURL, response, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let tempURL else {
continuation.resume(throwing: NetworkError.invalidResponse)
return
}
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: tempURL, to: destination)
continuation.resume(returning: destination)
} catch {
continuation.resume(throwing: error)
}
}
self.downloadTask = task
task.resume()
}
}
func cancel() {
downloadTask?.cancel()
}
}
extension DownloadManager: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// Handled in completion handler
}
}
Multipart Upload
func uploadImage(_ image: UIImage, to url: URL) async throws -> UploadResult {
let boundary = UUID().uuidString
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
guard let imageData = image.jpegData(compressionQuality: 0.8) else {
throw NetworkError.invalidResponse
}
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"image\"; filename=\"photo.jpg\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
let (data, _) = try await URLSession.shared.upload(for: request, from: body)
return try JSONDecoder().decode(UploadResult.self, from: data)
}
Request Retry with Exponential Backoff
func withRetry<T>(
maxAttempts: Int = 3,
initialDelay: Duration = .seconds(1),
operation: () async throws -> T
) async throws -> T {
var lastError: Error?
var delay = initialDelay
for attempt in 1...maxAttempts {
do {
return try await operation()
} catch {
lastError = error
let nsError = error as NSError
let isRetryable = [NSURLErrorTimedOut, NSURLErrorNetworkConnectionLost]
.contains(nsError.code)
if !isRetryable || attempt == maxAttempts { throw error }
try await Task.sleep(for: delay)
delay *= 2
}
}
throw lastError!
}
Best Practices
- Use an
actorfor your API client to serialize token refresh and prevent duplicate auth requests. - Model errors explicitly with a typed
NetworkErrorenum so callers can handle specific failure modes (no connection, auth expired, server error). - Configure
JSONDecoderonce with date/key strategies and reuse it. Creating a decoder per request is wasteful. - Set timeouts on
URLSessionConfiguration. The default 60-second timeout is too long for most API calls. Use 15-30 seconds for API requests. - Use
URLSession.sharedfor simple requests and custom sessions only when you need delegate callbacks (progress, background transfers, certificate pinning). - Respect cancellation. URLSession tasks support cancellation natively. Cancelling a
Taskthat is awaitingURLSession.data(for:)cancels the underlying network request. - Use
URLCacheand HTTP caching headers for read-heavy endpoints instead of building a custom cache.
Common Pitfalls
- Not checking the HTTP status code.
URLSessiondoes not throw on 4xx/5xx responses. Always verify the status code before decoding. - Decoding on the main thread.
JSONDecoder.decodefor large payloads blocks the main thread. Perform decoding in a background context or useTask.detachedfor heavy parsing. - Ignoring
URLSessiondelegate retain cycles. AURLSessionstrongly retains its delegate. Invalidate the session when done (finishTasksAndInvalidate()). - Hardcoding base URLs. Use build configurations or environment-based injection so staging, QA, and production URLs are separate.
- Forgetting background session rules. Background
URLSessionuploads/downloads must use file-based transfer (not data tasks). The system relaunches your app to deliver results.
Install this skill directly: skilldb add ios-swift-skills
Related Skills
App Architecture
MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications
Combine
Combine framework reactive programming patterns for handling asynchronous events in iOS
Core Data
Core Data persistence framework for modeling, storing, and querying structured data in iOS apps
Navigation
SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking
Swift Concurrency
Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code
Swiftui
SwiftUI declarative UI framework fundamentals for building modern iOS interfaces