Before writing a single line of URLSession code, you must deeply understand what happens under the hood. Every performance problem, every weird retry behavior, every TLS error — all of it traces back to this layer.
When your iOS app makes a network request, data travels through a layered model. At the top sits your Swift code. Below that, URLSession speaks HTTP/HTTPS. Below that, TCP handles reliable delivery. Below that, IP handles routing. At the bottom, the physical/wireless layer moves bits.
The TCP Handshake is a three-step process: SYN → SYN-ACK → ACK. This round-trip costs one RTT (Round-Trip Time) before a single byte of application data can be sent. On a mobile network with 50ms RTT, this means 50ms of pure overhead before your request even starts. This is why connection reuse (HTTP keep-alive) is so important — it amortizes this cost.
TLS adds more overhead. TLS 1.2 requires two additional round trips (2 RTTs) after the TCP handshake before encrypted data can flow. TLS 1.3, which iOS 13+ supports, reduces this to one RTT, and even supports 0-RTT resumption for sessions where a prior connection exists. This is why upgrading to TLS 1.3 is a measurable performance win for mobile apps.
HTTP/1.1 is request-response with keep-alive. Each connection can only serve one in-flight request at a time (head-of-line blocking). Browsers and URLSession work around this by opening multiple parallel connections (typically 6 per host), but each connection still has TCP + TLS overhead.
HTTP/2 solves head-of-line blocking at the HTTP layer with multiplexing. Multiple requests and responses are interleaved over a single TCP connection as independent "streams." A single connection serves all traffic to a host. It also uses HPACK header compression (since headers like Authorization and Content-Type are repeated constantly, this is a major bandwidth saving) and supports server push.
However, HTTP/2 still suffers from TCP-level head-of-line blocking. If a TCP packet is lost, all HTTP/2 streams on that connection stall waiting for retransmission — this can actually make HTTP/2 worse than HTTP/1.1 on lossy networks.
HTTP/3 solves this by running over QUIC instead of TCP. QUIC is a UDP-based protocol that builds reliability, ordering, and stream multiplexing directly into itself, so a single lost packet only blocks the stream that owns that data, not all streams. QUIC also integrates TLS 1.3, reducing connection establishment overhead further.
iOS 15+ with URLSession supports HTTP/3 natively. You can check which protocol was used via URLSessionTaskMetrics.
// Inspect which protocol was used
func urlSession(_ session: URLSession, task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
for transaction in metrics.transactionMetrics {
print("Protocol: \(transaction.networkProtocolName ?? "unknown")")
// Could be "h3", "h2", "http/1.1"
print("DNS start: \(transaction.domainLookupStartDate ?? Date())")
print("Connect start: \(transaction.connectStartDate ?? Date())")
print("TLS start: \(transaction.secureConnectionStartDate ?? Date())")
print("Request start: \(transaction.requestStartDate ?? Date())")
print("Response start: \(transaction.responseStartDate ?? Date())")
print("Response end: \(transaction.responseEndDate ?? Date())")
}
}
The URLSessionTaskMetrics timestamps let you break down exactly where your latency is coming from — DNS, TCP connect, TLS, TTFB (time to first byte), or download. This is invaluable for diagnosing real-world performance issues.
DNS resolution itself can add 20–200ms on a cold request. URLSession caches DNS results, and your URLSessionConfiguration controls how long. The system also does DNS prefetching for known hosts. On a warm connection, all of the TCP + TLS overhead disappears — you get straight to sending your request. This is why the difference between a "cold" first request and subsequent warm requests can be dramatic.
URLSession is Apple's networking API, and understanding its internals makes you dramatically more effective with it.
A URLSession is a factory for URLSessionTask objects. The session holds the configuration, delegate, and delegate queue. Tasks are the actual units of work. There are four task types:
// The three fundamental ways to create a session
let shared = URLSession.shared // Singleton, no delegate, no customization, no background
let session = URLSession(configuration: .default) // Custom config, no delegate
let delegateSession = URLSession(
configuration: .default,
delegate: self, // Receives callbacks for auth, redirects, metrics, etc.
delegateQueue: .main // Queue on which delegate methods are called
)
Every URLSessionTask goes through a state machine: suspended → running → (suspended | canceling) → completed. Tasks are created in .suspended state and must be explicitly resumed:
let task = session.dataTask(with: url) { data, response, error in
// completion handler
}
task.resume() // Crucial! Forgetting this is a very common bug.
You can also suspend() a running task (pauses it, though the network request may have already started) and cancel() it. Cancellation is asynchronous — the completion handler still fires, with an error of URLError.cancelled.
iOS 15 added first-class async/await support directly on URLSession:
// Data task
let (data, response) = try await URLSession.shared.data(from: url)
// Upload task
let (data, response) = try await URLSession.shared.upload(for: request, from: bodyData)
// Download task — returns local URL of downloaded file
let (localURL, response) = try await URLSession.shared.download(from: url)
// Bytes — for streaming response bodies
let (asyncBytes, response) = try await URLSession.shared.bytes(from: url)
for try await byte in asyncBytes {
// Process byte by byte — useful for SSE (Server-Sent Events)
}
The async variants integrate cleanly with Swift's structured concurrency, including cooperative task cancellation. When the Swift Task is cancelled, the underlying URLSessionTask is cancelled automatically.
URLSessionConfiguration is where you control the behavior of the entire session. Getting this right is essential for correctness and performance.
// .default — disk-based cache, credential storage, cookie storage
// Uses the system's shared cache and credential store
let defaultConfig = URLSessionConfiguration.default
// .ephemeral — no cache, no cookies, no credentials persisted to disk
// Data only lives in RAM for the session's lifetime
// Perfect for: private browsing, sensitive data, testing
let ephemeralConfig = URLSessionConfiguration.ephemeral
// .background(withIdentifier:) — transfers continue when app is suspended or killed
// Critical for large uploads/downloads
// The identifier must be unique and consistent across app launches
let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.myapp.transfers")
var config = URLSessionConfiguration.default
// Timeout for initial connection establishment (default: 60s)
config.timeoutIntervalForRequest = 30.0
// Timeout for the entire resource — from first request byte to last response byte (default: 7 days!)
// The default is enormous — always set this to something reasonable
config.timeoutIntervalForResource = 300.0 // 5 minutes
// Cellular and WWAN control
config.allowsCellularAccess = true // Allow cellular (default: true)
config.allowsConstrainedNetworkAccess = true // Allow Low Data Mode (default: true)
config.allowsExpensiveNetworkAccess = true // Allow cellular/hotspot (default: true)
// HTTP/2 and HTTP/3
// HTTP/2 is enabled by default. HTTP/3 must be opted into:
config.requiresDNSSECValidation = false // iOS 16+
// Discretionary — system decides WHEN to transfer (great for background, not interactive)
config.isDiscretionary = false // For foreground work, this should be false
// HTTP Additional Headers — applied to every request in this session
config.httpAdditionalHeaders = [
"Accept": "application/json",
"X-App-Version": "2.1.0",
"X-Platform": "iOS"
]
// Note: Authorization headers set here persist across redirects to different domains — security risk!
// Cookie policy
config.httpCookieAcceptPolicy = .onlyFromMainDocumentDomain
config.httpShouldSetCookies = true
// Maximum simultaneous connections per host (default: 6 for HTTP/1.1, 1 for HTTP/2)
config.httpMaximumConnectionsPerHost = 4
// Protocol classes — custom URL protocol handlers (advanced use)
// config.protocolClasses = [MockURLProtocol.self] + (URLSessionConfiguration.default.protocolClasses ?? [])
// Waits for connectivity rather than immediately failing
// Essential for apps that should queue requests when offline
config.waitsForConnectivity = true
// Combined with the delegate method:
// urlSession(_:taskIsWaitingForConnectivity:) -> called while waiting
waitsForConnectivity In DepthThis is one of the most underused and impactful configuration options. When set to true, instead of immediately failing with URLError.notConnectedToInternet when there's no network, the session will hold the request and start it automatically when connectivity is restored.
extension NetworkManager: URLSessionTaskDelegate {
func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) {
// Update UI to show "Waiting for network..."
// This is called on the delegate queue
DispatchQueue.main.async {
self.connectionState = .waitingForConnectivity
}
}
}
This eliminates the need for manual retry logic in many cases — the system handles the wait for you, and the request fires automatically when the network returns.
var request = URLRequest(url: URL(string: "https://api.example.com/v1/users")!)
// HTTP Method
request.httpMethod = "POST"
// Headers
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
// URLSession automatically adds:
// - Host header
// - Content-Length (if body is set)
// - Accept-Encoding (gzip, deflate, br)
// - Connection: keep-alive
// Body
let payload = ["name": "Simran", "role": "Senior iOS Developer"]
request.httpBody = try? JSONEncoder().encode(payload)
// Cache policy (overrides session config for this request)
request.cachePolicy = .returnCacheDataElseLoad
// Timeout (overrides session config for this request)
request.timeoutInterval = 15.0
Never concatenate URL strings manually. Use URLComponents to safely handle encoding:
var components = URLComponents()
components.scheme = "https"
components.host = "api.example.com"
components.path = "/v1/search"
components.queryItems = [
URLQueryItem(name: "q", value: "swift networking"),
URLQueryItem(name: "limit", value: "20"),
URLQueryItem(name: "offset", value: "0"),
URLQueryItem(name: "filter", value: "active & verified") // Will be properly percent-encoded
]
guard let url = components.url else {
throw NetworkError.invalidURL
}
// Result: https://api.example.com/v1/search?q=swift%20networking&limit=20&offset=0&filter=active%20%26%20verified
URLComponents handles percent-encoding correctly, including characters that are valid in some URL positions but not others. Manual string building almost always gets this wrong for edge-case inputs.
Understanding HTTP verb semantics is critical for correct retry logic:
For POST operations that must be exactly-once, use idempotency keys:
// Generate a stable key for this operation
let idempotencyKey = UUID().uuidString
request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
// Persist the key so if the app restarts mid-retry, same key is used
UserDefaults.standard.set(idempotencyKey, forKey: "pendingPaymentIdempotencyKey")
A production network layer must handle status codes precisely:
enum HTTPStatusCategory {
case informational // 1xx
case success // 2xx
case redirection // 3xx
case clientError // 4xx — do NOT retry, fix the request
case serverError // 5xx — may retry with backoff
case unknown
}
extension HTTPURLResponse {
var statusCategory: HTTPStatusCategory {
switch statusCode {
case 100..<200: return .informational
case 200..<300: return .success
case 300..<400: return .redirection
case 400..<500: return .clientError
case 500..<600: return .serverError
default: return .unknown
}
}
// Key status codes to handle explicitly:
// 200 OK, 201 Created, 204 No Content
// 301 Moved Permanently (update saved URLs), 302 Found (temporary redirect)
// 304 Not Modified (use cached response)
// 400 Bad Request (client bug — log and fix)
// 401 Unauthorized (refresh token and retry)
// 403 Forbidden (don't retry — user lacks permission)
// 404 Not Found (resource gone)
// 409 Conflict (e.g., duplicate resource)
// 429 Too Many Requests (respect Retry-After header)
// 503 Service Unavailable (retry with backoff)
}
Codable is the standard, but using it at production quality requires understanding its edge cases:
// Basic usage
struct User: Codable {
let id: Int
let name: String
let email: String
let createdAt: Date
let avatarURL: URL?
}
let decoder = JSONDecoder()
// Dates as ISO 8601 strings (common in modern APIs)
decoder.dateDecodingStrategy = .iso8601
// Dates as Unix timestamps
// decoder.dateDecodingStrategy = .secondsSince1970
// snake_case JSON → camelCase Swift
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)
Real-world APIs rarely have perfectly clean JSON. Custom Decodable implementations handle the mess:
struct APIResponse<T: Decodable>: Decodable {
let data: T
let metadata: Metadata
struct Metadata: Decodable {
let total: Int
let page: Int
let perPage: Int
}
}
// Handling polymorphic types with a "type" discriminator field
enum EventPayload: Decodable {
case purchase(PurchaseEvent)
case pageView(PageViewEvent)
case unknown
private enum CodingKeys: String, CodingKey {
case type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
let singleContainer = try decoder.singleValueContainer()
switch type {
case "purchase":
self = .purchase(try singleContainer.decode(PurchaseEvent.self))
case "page_view":
self = .pageView(try singleContainer.decode(PageViewEvent.self))
default:
self = .unknown
}
}
}
// Handling missing vs. null — different meanings!
struct Profile: Decodable {
let displayName: String
let bio: String? // null in JSON — user has no bio
// If "bio" key is missing entirely from JSON, decoding fails
// To handle missing keys:
private enum CodingKeys: String, CodingKey {
case displayName, bio
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
displayName = try container.decode(String.self, forKey: .displayName)
bio = try container.decodeIfPresent(String.self, forKey: .bio)
// decodeIfPresent returns nil for both missing key AND null value
}
}
APIs often return error bodies with useful information. Decode them:
struct APIError: Decodable, Error {
let code: String
let message: String
let details: [String: String]?
}
enum NetworkError: Error {
case httpError(statusCode: Int, apiError: APIError?)
case decodingError(DecodingError)
case transportError(URLError)
case unknown(Error)
}
func decode<T: Decodable>(_ type: T.Type, from data: Data, response: URLResponse) throws -> T {
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.unknown(URLError(.badServerResponse))
}
guard httpResponse.statusCategory == .success else {
// Try to decode the error body
let apiError = try? JSONDecoder().decode(APIError.self, from: data)
throw NetworkError.httpError(statusCode: httpResponse.statusCode, apiError: apiError)
}
// Handle 204 No Content
if httpResponse.statusCode == 204 {
// Return EmptyResponse or handle specially
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch let error as DecodingError {
// Log the full DecodingError — it contains the exact path that failed
// e.g., "Expected String at path: user.address.city"
throw NetworkError.decodingError(error)
}
}
Structure your response handling as a pipeline of validations:
func validate(_ data: Data, _ response: URLResponse, _ error: Error?) throws -> Data {
// 1. Check transport error
if let error = error as? URLError {
throw NetworkError.transportError(error)
} else if let error = error {
throw NetworkError.unknown(error)
}
// 2. Validate HTTP response
guard let http = response as? HTTPURLResponse else {
throw NetworkError.unknown(URLError(.badServerResponse))
}
// 3. Check status code
switch http.statusCode {
case 200...299: break // Success — continue
case 401: throw NetworkError.unauthorized
case 403: throw NetworkError.forbidden
case 404: throw NetworkError.notFound
case 429:
let retryAfter = http.value(forHTTPHeaderField: "Retry-After")
.flatMap(Double.init) ?? 60.0
throw NetworkError.rateLimited(retryAfter: retryAfter)
case 500...599: throw NetworkError.serverError(statusCode: http.statusCode)
default: throw NetworkError.httpError(statusCode: http.statusCode, apiError: nil)
}
// 4. Return data for decoding
return data
}
JWT (JSON Web Tokens) are the most common API authentication mechanism. A production implementation handles token refresh transparently:
actor TokenStore {
private var accessToken: String?
private var refreshToken: String?
private var accessTokenExpiry: Date?
private var refreshTask: Task<String, Error>?
func validAccessToken() async throws -> String {
// If we have a valid, non-expired token, return it
if let token = accessToken,
let expiry = accessTokenExpiry,
expiry > Date().addingTimeInterval(60) { // 60s buffer before expiry
return token
}
// If a refresh is already in progress, wait for it
// This is the critical pattern — prevents multiple simultaneous refreshes
if let existingTask = refreshTask {
return try await existingTask.value
}
// Start a new refresh
let task = Task<String, Error> {
defer { refreshTask = nil }
return try await performTokenRefresh()
}
refreshTask = task
return try await task.value
}
private func performTokenRefresh() async throws -> String {
guard let refreshToken = refreshToken else {
throw AuthError.notAuthenticated
}
var request = URLRequest(url: URL(string: "https://auth.example.com/refresh")!)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(["refresh_token": refreshToken])
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
self.accessToken = response.accessToken
self.refreshToken = response.refreshToken
self.accessTokenExpiry = response.expiresAt
return response.accessToken
}
}
For server-challenge based auth (Basic Auth, client certificates, etc.):
extension NetworkManager: URLSessionTaskDelegate {
func urlSession(_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
let space = challenge.protectionSpace
switch space.authenticationMethod {
case NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodHTTPDigest:
// Server requires username/password
let credential = URLCredential(
user: "username",
password: "password",
persistence: .forSession
)
completionHandler(.useCredential, credential)
case NSURLAuthenticationMethodServerTrust:
// TLS server certificate validation
// Default behavior: let system validate
// For pinning, implement here (see Section 13)
completionHandler(.performDefaultHandling, nil)
case NSURLAuthenticationMethodClientCertificate:
// Client certificate authentication (mutual TLS)
guard let identity = loadClientIdentity() else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let credential = URLCredential(
identity: identity,
certificates: nil,
persistence: .forSession
)
completionHandler(.useCredential, credential)
default:
// For unknown challenges, cancel (don't use default handling — that may prompt user)
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
Never store tokens in UserDefaults. Always use Keychain:
import Security
enum KeychainError: Error {
case duplicateItem
case itemNotFound
case unexpectedStatus(OSStatus)
}
struct KeychainManager {
static let service = "com.myapp.credentials"
static func save(token: String, forKey key: String) throws {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
// Only accessible when device is unlocked (not background-accessible)
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
// Try to add
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
// Update existing
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.unexpectedStatus(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.unexpectedStatus(status)
}
}
static func load(key: String) throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status == errSecItemNotFound { throw KeychainError.itemNotFound }
throw KeychainError.unexpectedStatus(status)
}
return String(decoding: data, as: UTF8.self)
}
static func delete(key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
}
URLCache provides an on-disk and in-memory cache for HTTP responses. URLSession uses it automatically for cacheable responses (those with appropriate Cache-Control or Expires headers).
// Configure cache for your session
let cache = URLCache(
memoryCapacity: 20 * 1024 * 1024, // 20 MB in-memory
diskCapacity: 100 * 1024 * 1024, // 100 MB on-disk
directory: nil // nil = system default directory
)
let config = URLSessionConfiguration.default
config.urlCache = cache
// Cache policy options per-request:
// .useProtocolCachePolicy — follow server headers (default)
// .reloadIgnoringLocalCacheData — always hit network
// .returnCacheDataElseLoad — use cache if available, otherwise load
// .returnCacheDataDontLoad — use cache only, fail if not cached (offline mode)
// .reloadRevalidatingCacheData — conditional GET (If-None-Match / If-Modified-Since)
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad
// Manual cache interaction
if let cached = URLCache.shared.cachedResponse(for: request) {
let response = cached.response as? HTTPURLResponse
print("Cached at: \(cached.storagePolicy)")
}
// Manually cache a response (useful for pre-caching, or caching non-standard responses)
let cachedResponse = CachedURLResponse(
response: urlResponse,
data: data,
userInfo: ["cachedAt": Date()],
storagePolicy: .allowed // or .allowedInMemoryOnly, .notAllowed
)
URLCache.shared.storeCachedResponse(cachedResponse, for: request)
// Invalidate specific URL
URLCache.shared.removeCachedResponse(for: request)
// Clear all cache
URLCache.shared.removeAllCachedResponses()
The ETag and Last-Modified headers enable conditional requests, which are extremely efficient — if the resource hasn't changed, the server returns 304 Not Modified with no body:
// First request: server returns ETag and/or Last-Modified headers
// Cache them:
let etag = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "ETag")
let lastModified = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Last-Modified")
// Subsequent request: include validators
var request = URLRequest(url: url)
if let etag = etag {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
if let lastModified = lastModified {
request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since")
}
// If server returns 304: use your cached data
// If server returns 200: update your cache with new ETag + data
URLSession with .useProtocolCachePolicy handles this automatically when the server sends proper caching headers. But many APIs don't set these headers correctly, so implementing it manually gives you control.
For APIs that don't use HTTP caching headers but where you want caching, implement your own layer:
// A simple time-to-live cache
actor ResponseCache {
private struct CacheEntry {
let data: Data
let timestamp: Date
let ttl: TimeInterval
var isExpired: Bool {
Date().timeIntervalSince(timestamp) > ttl
}
}
private var store: [String: CacheEntry] = [:]
func get(key: String) -> Data? {
guard let entry = store[key], !entry.isExpired else {
store.removeValue(forKey: key)
return nil
}
return entry.data
}
func set(key: String, data: Data, ttl: TimeInterval) {
store[key] = CacheEntry(data: data, timestamp: Date(), ttl: ttl)
}
func invalidate(prefix: String) {
store = store.filter { !$0.key.hasPrefix(prefix) }
}
}
Background transfers are one of the most misunderstood areas of iOS networking. They're essential for reliable large file transfers.
// Background sessions MUST be created with the same identifier after app relaunch
// The identifier must be unique per app
class BackgroundTransferManager: NSObject {
static let backgroundIdentifier = "com.myapp.background.transfers"
private(set) lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: Self.backgroundIdentifier)
config.isDiscretionary = false // false = transfer ASAP, true = system decides (better battery)
config.sessionSendsLaunchEvents = true // Wakes app when transfer completes
config.allowsCellularAccess = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
// CRITICAL: Call this in AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:)
var backgroundCompletionHandler: (() -> Void)?
}
// AppDelegate.swift
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// Reconnect to the background session (creates it if needed) and save the handler
BackgroundTransferManager.shared.backgroundCompletionHandler = completionHandler
// Accessing BackgroundTransferManager.shared.session reconnects to the background session
_ = BackgroundTransferManager.shared.session
}
// Start a background download
func startDownload(url: URL) {
// For background downloads, use a URLRequest not just a URL
let request = URLRequest(url: url)
let task = session.downloadTask(with: request)
task.taskDescription = "file-\(url.lastPathComponent)" // For identification after relaunch
task.resume()
}
extension BackgroundTransferManager: URLSessionDownloadDelegate {
// Called when download completes (may be in background)
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// IMPORTANT: location is a temporary file — move it IMMEDIATELY
// This callback happens on the delegate queue; location will be deleted when this returns
let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(downloadTask.taskDescription ?? "download")
do {
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: location, to: dest)
DispatchQueue.main.async { self.notifyDownloadComplete(url: dest) }
} catch {
print("Failed to move downloaded file: \(error)")
}
}
// Progress updates
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
let progress = totalBytesExpectedToWrite > 0
? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
: 0
DispatchQueue.main.async { self.updateProgress(progress, for: downloadTask) }
}
// Resumable downloads — save resume data on failure
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error as NSError?,
let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
// Save resume data to disk for later
let resumeURL = resumeDataURL(for: task)
try? resumeData.write(to: resumeURL)
}
}
// CRITICAL: Called when all background events have been delivered
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
// Call the completion handler to tell the system we're done
// Failure to call this may cause app termination
self.backgroundCompletionHandler?()
self.backgroundCompletionHandler = nil
}
}
}
func resumeDownload(taskDescription: String) {
let resumeURL = resumeDataURL(for: taskDescription)
if let resumeData = try? Data(contentsOf: resumeURL) {
// Resume from where we left off (no re-download of already-received bytes)
let task = session.downloadTask(withResumeData: resumeData)
task.taskDescription = taskDescription
task.resume()
try? FileManager.default.removeItem(at: resumeURL)
} else {
// No resume data available — start fresh
startDownload(url: originalURL)
}
}
WebSockets provide a persistent, bidirectional channel over a single TCP connection. Essential for real-time features: chat, live prices, collaborative editing, live sports scores.
class WebSocketManager: NSObject {
private var webSocketTask: URLSessionWebSocketTask?
private var pingTimer: Timer?
private var isConnected = false
private lazy var session: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}()
func connect(to url: URL) {
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
isConnected = true
startReceiving()
schedulePing()
}
// IMPORTANT: You must continuously call receive() to keep getting messages
// It's a one-shot call — each receive() delivers exactly one message
private func startReceiving() {
webSocketTask?.receive { [weak self] result in
guard let self = self, self.isConnected else { return }
switch result {
case .success(let message):
self.handleMessage(message)
self.startReceiving() // Schedule next receive immediately
case .failure(let error):
self.handleDisconnection(error: error)
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .string(let text):
// Parse JSON text message
guard let data = text.data(using: .utf8),
let event = try? JSONDecoder().decode(WebSocketEvent.self, from: data) else {
return
}
handleEvent(event)
case .data(let binaryData):
// Handle binary message (e.g., Protocol Buffers)
handleBinaryData(binaryData)
@unknown default:
break
}
}
func send(_ event: Encodable) {
guard let data = try? JSONEncoder().encode(event),
let text = String(data: data, encoding: .utf8) else { return }
webSocketTask?.send(.string(text)) { [weak self] error in
if let error = error {
self?.handleSendError(error)
}
}
}
// Ping/Pong keeps the connection alive through NAT/load balancers
// Most WebSocket servers disconnect idle connections after 30–60 seconds
private func schedulePing() {
pingTimer = Timer.scheduledTimer(withTimeInterval: 25.0, repeats: true) { [weak self] _ in
self?.webSocketTask?.sendPing { error in
if let error = error {
print("Ping failed: \(error)")
// Connection is dead — trigger reconnect
}
}
}
}
func disconnect(code: URLSessionWebSocketTask.CloseCode = .normalClosure) {
isConnected = false
pingTimer?.invalidate()
webSocketTask?.cancel(with: code, reason: nil)
webSocketTask = nil
}
}
extension WebSocketManager: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?) {
print("WebSocket connected, protocol: \(`protocol` ?? "none")")
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "none"
print("WebSocket closed: \(closeCode), reason: \(reasonString)")
handleDisconnection(error: nil)
}
}
Production WebSocket clients need exponential backoff reconnection:
class ReconnectingWebSocketManager: WebSocketManager {
private var reconnectAttempts = 0
private let maxReconnectAttempts = 10
private var reconnectTask: Task<Void, Never>?
override func handleDisconnection(error: Error?) {
guard reconnectAttempts < maxReconnectAttempts else {
notifyPermanentDisconnection()
return
}
reconnectTask = Task {
let delay = min(pow(2.0, Double(reconnectAttempts)), 60.0) // Cap at 60s
let jitter = Double.random(in: 0...1.0) // Jitter prevents thundering herd
try? await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000))
guard !Task.isCancelled else { return }
reconnectAttempts += 1
connect(to: serverURL)
}
}
override func connect(to url: URL) {
reconnectAttempts = 0
super.connect(to: url)
}
}
Async/await is now the preferred approach for all new networking code on iOS 15+. It composes cleanly with Swift's structured concurrency:
struct NetworkClient {
private let session: URLSession
private let tokenStore: TokenStore
private let baseURL: URL
func request<T: Decodable>(
_ endpoint: Endpoint,
as type: T.Type = T.self
) async throws -> T {
let token = try await tokenStore.validAccessToken()
var request = try endpoint.urlRequest(baseURL: baseURL)
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...299:
return try JSONDecoder().decode(T.self, from: data)
case 401:
// Token was invalid — clear token and throw
await tokenStore.clearTokens()
throw NetworkError.unauthorized
default:
let apiError = try? JSONDecoder().decode(APIError.self, from: data)
throw NetworkError.httpError(statusCode: http.statusCode, apiError: apiError)
}
}
}
// Usage — beautifully linear, no callbacks
func loadUserProfile() async throws -> User {
let user = try await networkClient.request(.getUser(id: currentUserID), as: User.self)
return user
}
// Parallel requests
func loadDashboard() async throws -> Dashboard {
async let user = networkClient.request(.getUser(id: currentUserID), as: User.self)
async let feed = networkClient.request(.getFeed, as: [FeedItem].self)
async let notifications = networkClient.request(.getNotifications, as: [Notification].self)
return try await Dashboard(
user: user,
feed: feed,
notifications: notifications
)
}
// Graceful cancellation
func loadWithCancellation() async {
let task = Task {
do {
let result = try await networkClient.request(.heavyQuery, as: LargeDataset.self)
await MainActor.run { self.data = result }
} catch is CancellationError {
print("Request was cancelled")
} catch {
await MainActor.run { self.error = error }
}
}
// Store task reference to cancel later
self.currentTask = task
}
While async/await is preferred for new code, Combine is still valuable for declarative data pipelines and when integrating with existing Combine-based code:
extension URLSession {
func publisher<T: Decodable>(
for request: URLRequest,
as type: T.Type,
decoder: JSONDecoder = .init()
) -> AnyPublisher<T, NetworkError> {
dataTaskPublisher(for: request)
.tryMap { output in
guard let http = output.response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200...299 ~= http.statusCode else {
throw NetworkError.httpError(statusCode: http.statusCode, apiError: nil)
}
return output.data
}
.decode(type: T.self, decoder: decoder)
.mapError { error in
switch error {
case let netError as NetworkError: return netError
case let decodingError as DecodingError: return .decodingError(decodingError)
case let urlError as URLError: return .transportError(urlError)
default: return .unknown(error)
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
// Usage
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var error: NetworkError?
private var cancellables = Set<AnyCancellable>()
func loadUser(id: Int) {
let request = URLRequest(url: URL(string: "https://api.example.com/users/\(id)")!)
URLSession.shared.publisher(for: request, as: User.self)
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error
}
}, receiveValue: { [weak self] user in
self?.user = user
})
.store(in: &cancellables)
}
}
SSE (Server-Sent Events) is a simple one-directional streaming protocol — the server pushes events to the client over a persistent HTTP connection. Common for live notifications, progress updates, and AI streaming responses:
func streamEvents(from url: URL) async throws {
var request = URLRequest(url: url)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
let (asyncBytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw NetworkError.invalidResponse
}
var currentEvent: SSEEvent = SSEEvent()
for try await line in asyncBytes.lines {
if line.isEmpty {
// Empty line signals end of event — dispatch it
if !currentEvent.data.isEmpty {
await handleSSEEvent(currentEvent)
currentEvent = SSEEvent()
}
continue
}
if line.hasPrefix("data:") {
let data = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
currentEvent.data += data
} else if line.hasPrefix("event:") {
currentEvent.event = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("id:") {
currentEvent.id = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("retry:") {
let retryMs = Int(line.dropFirst(6).trimmingCharacters(in: .whitespaces))
currentEvent.retry = retryMs
}
}
}
struct SSEEvent {
var event: String = "message"
var data: String = ""
var id: String?
var retry: Int?
}
NWPathMonitor from the Network framework provides fine-grained, real-time network status — far superior to the old Reachability pattern:
import Network
final class NetworkMonitor {
static let shared = NetworkMonitor()
private let monitor: NWPathMonitor
private let queue = DispatchQueue(label: "NetworkMonitor", qos: .utility)
@Published private(set) var isConnected: Bool = true
@Published private(set) var connectionType: ConnectionType = .unknown
@Published private(set) var isExpensive: Bool = false // Cellular or hotspot
@Published private(set) var isConstrained: Bool = false // Low Data Mode
enum ConnectionType: String {
case wifi = "WiFi"
case cellular = "Cellular"
case ethernet = "Ethernet"
case loopback = "Loopback"
case other = "Other"
case unknown = "Unknown"
}
private init() {
monitor = NWPathMonitor()
}
func start() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
let connected = path.status == .satisfied
let expensive = path.isExpensive
let constrained = path.isConstrained
let type: ConnectionType
if path.usesInterfaceType(.wifi) {
type = .wifi
} else if path.usesInterfaceType(.cellular) {
type = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
type = .ethernet
} else if path.usesInterfaceType(.loopback) {
type = .loopback
} else if path.status == .satisfied {
type = .other
} else {
type = .unknown
}
DispatchQueue.main.async {
self.isConnected = connected
self.connectionType = type
self.isExpensive = expensive
self.isConstrained = constrained
}
}
monitor.start(queue: queue)
}
func stop() {
monitor.cancel()
}
// Use this to adapt behavior based on network quality
var shouldUseLowQualityMedia: Bool {
isConstrained || isExpensive
}
}
// Adapt request behavior based on network state
func buildImageURL(for asset: Asset) -> URL {
let monitor = NetworkMonitor.shared
if monitor.isConstrained || monitor.isExpensive {
// Low Data Mode or cellular — request compressed thumbnail
return asset.thumbnailURL(quality: .low)
} else if monitor.connectionType == .wifi {
return asset.fullResolutionURL
} else {
return asset.thumbnailURL(quality: .medium)
}
}
Multipart is the standard format for file uploads that include metadata:
struct MultipartFormData {
private let boundary: String
private var parts: [Part] = []
struct Part {
let name: String
let filename: String?
let contentType: String
let data: Data
}
init() {
boundary = "Boundary-\(UUID().uuidString)"
}
var contentType: String {
"multipart/form-data; boundary=\(boundary)"
}
mutating func add(field name: String, value: String) {
let data = Data(value.utf8)
parts.append(Part(name: name, filename: nil, contentType: "text/plain", data: data))
}
mutating func add(file data: Data, name: String, filename: String, mimeType: String) {
parts.append(Part(name: name, filename: filename, contentType: mimeType, data: data))
}
func encode() -> Data {
var body = Data()
let boundaryPrefix = "--\(boundary)\r\n"
for part in parts {
body.append(Data(boundaryPrefix.utf8))
var headers = "Content-Disposition: form-data; name=\"\(part.name)\""
if let filename = part.filename {
headers += "; filename=\"\(filename)\""
}
headers += "\r\n"
headers += "Content-Type: \(part.contentType)\r\n\r\n"
body.append(Data(headers.utf8))
body.append(part.data)
body.append(Data("\r\n".utf8))
}
body.append(Data("--\(boundary)--\r\n".utf8))
return body
}
}
// Usage
func uploadProfilePhoto(_ image: UIImage) async throws {
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
var form = MultipartFormData()
form.add(field: "userId", value: currentUser.id)
form.add(file: imageData, name: "photo", filename: "profile.jpg", mimeType: "image/jpeg")
let encoded = form.encode()
var request = URLRequest(url: URL(string: "https://api.example.com/profile/photo")!)
request.httpMethod = "POST"
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
request.setValue(String(encoded.count), forHTTPHeaderField: "Content-Length")
// Use uploadTask for progress reporting
let task = session.uploadTask(with: request, from: encoded)
task.resume()
}
For very large files, loading everything into memory is impractical. Stream from disk instead:
func streamUpload(fileURL: URL) async throws {
let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as! Int
var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
request.httpMethod = "PUT"
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.setValue(String(fileSize), forHTTPHeaderField: "Content-Length")
// httpBodyStream lets URLSession read from an InputStream
// Never loaded fully into memory
request.httpBodyStream = InputStream(url: fileURL)
let task = session.uploadTask(withStreamedRequest: request)
task.resume()
// Track progress via URLSessionTaskDelegate
// func urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:)
}
For huge files (videos, backups), use chunked/resumable upload — upload in fixed-size pieces and recover from failures without restarting:
class ResumableUploader {
private let chunkSize: Int = 5 * 1024 * 1024 // 5 MB chunks
func upload(fileURL: URL) async throws {
let fileSize = try fileSize(at: fileURL)
let uploadID = try await initiateUpload(fileURL: fileURL, totalSize: fileSize)
var offset = 0
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
while offset < fileSize {
let remaining = fileSize - offset
let currentChunkSize = min(chunkSize, remaining)
try handle.seek(toOffset: UInt64(offset))
let chunk = handle.readData(ofLength: currentChunkSize)
let contentRange = "bytes \(offset)-\(offset + currentChunkSize - 1)/\(fileSize)"
try await uploadChunk(chunk, uploadID: uploadID, contentRange: contentRange)
offset += currentChunkSize
}
try await finalizeUpload(uploadID: uploadID)
}
private func uploadChunk(_ data: Data, uploadID: String, contentRange: String) async throws {
var request = URLRequest(url: URL(string: "https://api.example.com/uploads/\(uploadID)")!)
request.httpMethod = "PUT"
request.setValue(contentRange, forHTTPHeaderField: "Content-Range")
let (_, response) = try await session.upload(for: request, from: data)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 || http.statusCode == 308 else {
throw UploadError.chunkFailed
}
}
}
Certificate pinning prevents man-in-the-middle attacks by refusing connections to servers that don't present a specific certificate or public key. It's an extra layer of defense against attacks that compromise a Certificate Authority.
Pinning the public key is better than pinning the whole certificate — certificates can be rotated (e.g., annual renewal) without changing the public key, which means you don't need an app update to rotate certs:
extension NetworkManager: URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Let the system validate the certificate chain first (expiry, CA trust, etc.)
var error: CFError?
let isValid = SecTrustEvaluateWithError(serverTrust, &error)
guard isValid else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Then check public key hash against our pinned values
guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverPublicKeyHash = publicKeyHash(for: serverCertificate)
let pinnedHashes: Set<String> = [
// Extract with: openssl s_client -connect api.example.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64
"abc123...your-pinned-sha256-hash-base64-encoded",
"def456...backup-pin-for-key-rotation" // Always include at least one backup!
]
if pinnedHashes.contains(serverPublicKeyHash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
// Log this — it may indicate an attack or a pin that needs updating
reportPinningFailure(host: challenge.protectionSpace.host)
}
}
private func publicKeyHash(for certificate: SecCertificate) -> String {
guard let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
return ""
}
// SHA-256 hash the raw public key data
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
publicKeyData.withUnsafeBytes { bytes in
_ = CC_SHA256(bytes.baseAddress, CC_LONG(publicKeyData.count), &hash)
}
return Data(hash).base64EncodedString()
}
}
Enforce TLS 1.3 minimum in your Info.plist and session config:
<!-- Info.plist — App Transport Security -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<!-- For specific domains requiring exceptions: -->
<key>NSExceptionDomains</key>
<dict>
<key>staging.internal.com</key>
<dict>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
</dict>
</dict>
</dict>
Naive retry logic (try again immediately) can overwhelm a struggling server and worsen outages. Exponential backoff with jitter spreads retries over time:
struct RetryPolicy {
let maxRetries: Int
let baseDelay: TimeInterval
let maxDelay: TimeInterval
let multiplier: Double
let jitterRange: ClosedRange<Double>
static let `default` = RetryPolicy(
maxRetries: 3,
baseDelay: 1.0,
maxDelay: 60.0,
multiplier: 2.0,
jitterRange: 0...0.5
)
func delay(for attempt: Int) -> TimeInterval {
let exponential = baseDelay * pow(multiplier, Double(attempt))
let capped = min(exponential, maxDelay)
let jitter = Double.random(in: jitterRange)
return capped + jitter
}
// Only certain errors are worth retrying
func shouldRetry(error: Error, statusCode: Int?) -> Bool {
// Don't retry client errors — they won't change
if let code = statusCode, (400..<500).contains(code), code != 429 {
return false
}
if let urlError = error as? URLError {
switch urlError.code {
case .timedOut, .networkConnectionLost, .notConnectedToInternet:
return true
case .cancelled:
return false // Intentional cancellation
default:
return false
}
}
// Retry 429 (rate limited) and 5xx server errors
if let code = statusCode, code == 429 || (500..<600).contains(code) {
return true
}
return false
}
}
func requestWithRetry<T: Decodable>(
_ request: URLRequest,
policy: RetryPolicy = .default,
as type: T.Type
) async throws -> T {
var lastError: Error?
for attempt in 0...policy.maxRetries {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
// Check if we should retry based on status code
if policy.shouldRetry(error: URLError(.unknown), statusCode: http.statusCode)
&& attempt < policy.maxRetries {
// Respect Retry-After header if present (429 responses often include it)
if let retryAfter = http.value(forHTTPHeaderField: "Retry-After"),
let delay = Double(retryAfter) {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} else {
let delay = policy.delay(for: attempt)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
continue
}
return try JSONDecoder().decode(T.self, from: data)
} catch {
lastError = error
if policy.shouldRetry(error: error, statusCode: nil) && attempt < policy.maxRetries {
let delay = policy.delay(for: attempt)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
continue
} else {
throw error
}
}
}
throw lastError ?? NetworkError.maxRetriesExceeded
}
A circuit breaker prevents your app from repeatedly hammering a failing service. After a threshold of failures, it "trips" and immediately fails requests (fast-fail), preventing timeout cascades:
actor CircuitBreaker {
enum State {
case closed // Normal operation
case open // Failing — reject requests immediately
case halfOpen // Testing if the service has recovered
}
private var state: State = .closed
private var failureCount = 0
private var lastFailureTime: Date?
let failureThreshold: Int
let timeout: TimeInterval // Time in open state before moving to half-open
let successThreshold: Int // Successes in half-open to move to closed
private var halfOpenSuccessCount = 0
init(failureThreshold: Int = 5, timeout: TimeInterval = 30, successThreshold: Int = 2) {
self.failureThreshold = failureThreshold
self.timeout = timeout
self.successThreshold = successThreshold
}
func execute<T>(_ operation: () async throws -> T) async throws -> T {
switch state {
case .open:
// Check if timeout has passed — if so, move to half-open
if let lastFailure = lastFailureTime,
Date().timeIntervalSince(lastFailure) > timeout {
state = .halfOpen
halfOpenSuccessCount = 0
} else {
throw CircuitBreakerError.circuitOpen
}
fallthrough
case .halfOpen:
do {
let result = try await operation()
halfOpenSuccessCount += 1
if halfOpenSuccessCount >= successThreshold {
state = .closed
failureCount = 0
}
return result
} catch {
state = .open
lastFailureTime = Date()
throw error
}
case .closed:
do {
let result = try await operation()
failureCount = 0 // Reset on success
return result
} catch {
failureCount += 1
lastFailureTime = Date()
if failureCount >= failureThreshold {
state = .open
}
throw error
}
}
}
}
Putting it all together into a clean, testable, and maintainable architecture:
// Defines what a request is, not how to execute it
enum Endpoint {
// Auth
case login(email: String, password: String)
case refreshToken(String)
// Users
case getUser(id: String)
case updateUser(id: String, update: UserUpdate)
case uploadAvatar(id: String, imageData: Data)
// Feed
case getFeed(page: Int, limit: Int)
case likePost(id: String)
var method: HTTPMethod {
switch self {
case .login, .refreshToken, .likePost:
return .post
case .getUser, .getFeed:
return .get
case .updateUser:
return .patch
case .uploadAvatar:
return .put
}
}
var path: String {
switch self {
case .login: return "/auth/login"
case .refreshToken: return "/auth/refresh"
case .getUser(let id): return "/users/\(id)"
case .updateUser(let id, _): return "/users/\(id)"
case .uploadAvatar(let id, _): return "/users/\(id)/avatar"
case .getFeed: return "/feed"
case .likePost(let id): return "/posts/\(id)/like"
}
}
var queryItems: [URLQueryItem] {
switch self {
case .getFeed(let page, let limit):
return [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit))
]
default: return []
}
}
var body: Encodable? {
switch self {
case .login(let email, let password):
return ["email": email, "password": password]
case .refreshToken(let token):
return ["refresh_token": token]
case .updateUser(_, let update):
return update
default: return nil
}
}
var requiresAuth: Bool {
switch self {
case .login, .refreshToken: return false
default: return true
}
}
func urlRequest(baseURL: URL, encoder: JSONEncoder = .init()) throws -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)!
if !queryItems.isEmpty { components.queryItems = queryItems }
guard let url = components.url else { throw NetworkError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
if let body = body {
request.httpBody = try encoder.encode(AnyEncodable(body))
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
final class NetworkClient {
private let baseURL: URL
private let session: URLSession
private let tokenStore: TokenStore
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private let circuitBreaker: CircuitBreaker
private let retryPolicy: RetryPolicy
init(
baseURL: URL,
session: URLSession = .shared,
tokenStore: TokenStore,
retryPolicy: RetryPolicy = .default
) {
self.baseURL = baseURL
self.session = session
self.tokenStore = tokenStore
self.retryPolicy = retryPolicy
self.circuitBreaker = CircuitBreaker()
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
}
func send<T: Decodable>(_ endpoint: Endpoint, as type: T.Type = T.self) async throws -> T {
return try await circuitBreaker.execute {
try await self.executeWithRetry(endpoint: endpoint, type: type)
}
}
// For void responses (204 No Content, etc.)
func send(_ endpoint: Endpoint) async throws {
_ = try await circuitBreaker.execute {
try await self.executeRaw(endpoint: endpoint)
}
}
private func executeWithRetry<T: Decodable>(
endpoint: Endpoint,
type: T.Type,
attempt: Int = 0
) async throws -> T {
do {
let (data, response) = try await executeRaw(endpoint: endpoint)
let http = response as! HTTPURLResponse
let validData = try validateResponse(data: data, response: http)
return try decoder.decode(T.self, from: validData)
} catch NetworkError.unauthorized where endpoint.requiresAuth {
// Token expired mid-request — refresh and retry once
try await tokenStore.forceRefresh()
return try await executeWithRetry(endpoint: endpoint, type: type, attempt: attempt + 1)
} catch {
if retryPolicy.shouldRetry(error: error, statusCode: (error as? NetworkError)?.statusCode),
attempt < retryPolicy.maxRetries {
let delay = retryPolicy.delay(for: attempt)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
return try await executeWithRetry(endpoint: endpoint, type: type, attempt: attempt + 1)
}
throw error
}
}
@discardableResult
private func executeRaw(endpoint: Endpoint) async throws -> (Data, URLResponse) {
var request = try endpoint.urlRequest(baseURL: baseURL, encoder: encoder)
if endpoint.requiresAuth {
let token = try await tokenStore.validAccessToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Request ID for tracing
let requestID = UUID().uuidString
request.setValue(requestID, forHTTPHeaderField: "X-Request-ID")
return try await session.data(for: request)
}
private func validateResponse(data: Data, response: HTTPURLResponse) throws -> Data {
switch response.statusCode {
case 200...299: return data
case 401: throw NetworkError.unauthorized
case 403: throw NetworkError.forbidden
case 404: throw NetworkError.notFound
case 429:
let retryAfter = response.value(forHTTPHeaderField: "Retry-After")
.flatMap(Double.init) ?? 60.0
throw NetworkError.rateLimited(retryAfter: retryAfter)
default:
let apiError = try? decoder.decode(APIError.self, from: data)
throw NetworkError.httpError(statusCode: response.statusCode, apiError: apiError)
}
}
}
extension NetworkClient: URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
guard let transaction = metrics.transactionMetrics.last else { return }
let dnsTime = transaction.domainLookupEndDate?.timeIntervalSince(transaction.domainLookupStartDate ?? Date()) ?? 0
let connectTime = transaction.connectEndDate?.timeIntervalSince(transaction.connectStartDate ?? Date()) ?? 0
let tlsTime = transaction.secureConnectionEndDate?.timeIntervalSince(transaction.secureConnectionStartDate ?? Date()) ?? 0
let ttfb = transaction.responseStartDate?.timeIntervalSince(transaction.requestEndDate ?? Date()) ?? 0
let downloadTime = transaction.responseEndDate?.timeIntervalSince(transaction.responseStartDate ?? Date()) ?? 0
let totalTime = metrics.taskInterval.duration
print("""
Request: \(task.originalRequest?.url?.path ?? "")
DNS: \(String(format: "%.0fms", dnsTime * 1000))
Connect: \(String(format: "%.0fms", connectTime * 1000))
TLS: \(String(format: "%.0fms", tlsTime * 1000))
TTFB: \(String(format: "%.0fms", ttfb * 1000))
Download: \(String(format: "%.0fms", downloadTime * 1000))
Total: \(String(format: "%.0fms", totalTime * 1000))
Protocol: \(transaction.networkProtocolName ?? "unknown")
Reused: \(transaction.isReusedConnection)
Proxy: \(transaction.isProxyConnection)
""")
}
}
Connection reuse — Use a single URLSession instance per API domain, not a new one per request. Session creation is expensive, but more importantly, reusing sessions lets URLSession maintain persistent connections (HTTP keep-alive, HTTP/2 connection pooling).
Response compression — Ensure your server sends gzip/Brotli compressed responses. URLSession automatically decompresses when it sends Accept-Encoding: gzip, deflate, br. A typical JSON response compresses to 10–20% of its original size.
Reduce payload size — Request only the fields you need with field masks (?fields=id,name,email). Paginate large collections. Consider GraphQL for precise field selection.
Prioritize tasks — URLSession tasks have a priority property (0.0–1.0). Use URLSessionTask.highPriority (0.75) for user-initiated requests, URLSessionTask.defaultPriority (0.5) for normal requests, URLSessionTask.lowPriority (0.25) for background prefetch:
let task = session.dataTask(with: request)
task.priority = URLSessionTask.highPriority
task.resume()
Prefetching — When you can predict what the user will need next, prefetch with low-priority tasks. Store in URLCache or your custom cache. UICollectionView's prefetchDataSource is ideal for this pattern.
Image loading — Never load images on the main thread. Use a dedicated download + caching library or implement your own with NSCache for in-memory and URLCache for disk caching.
Testable network code is the mark of a senior engineer. The key is dependency injection — your network code should accept a URLSession-like protocol, not depend directly on URLSession.
URLProtocol is a powerful and underused feature that lets you intercept and mock network requests at the lowest level — without any dependency injection:
class MockURLProtocol: URLProtocol {
// Registry of handlers: URL path → (HTTPURLResponse, Data) or Error
static var handlers: [String: Result<(HTTPURLResponse, Data), Error>] = [:]
static var requestLog: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool {
return true // Handle all requests
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
MockURLProtocol.requestLog.append(request)
let path = request.url?.path ?? ""
if let result = MockURLProtocol.handlers[path] {
switch result {
case .success(let (response, data)):
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
case .failure(let error):
client?.urlProtocol(self, didFailWithError: error)
}
} else {
client?.urlProtocol(self, didFailWithError: URLError(.fileDoesNotExist))
}
}
override func stopLoading() {}
}
// In tests:
class NetworkClientTests: XCTestCase {
var client: NetworkClient!
var session: URLSession!
override func setUp() {
super.setUp()
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
session = URLSession(configuration: config)
client = NetworkClient(baseURL: URL(string: "https://api.example.com")!, session: session, tokenStore: MockTokenStore())
}
override func tearDown() {
MockURLProtocol.handlers = [:]
MockURLProtocol.requestLog = []
}
func testGetUser_success() async throws {
let expectedUser = User(id: "1", name: "Simran", email: "simran@example.com")
let data = try JSONEncoder().encode(expectedUser)
let response = HTTPURLResponse(url: URL(string: "https://api.example.com/users/1")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
MockURLProtocol.handlers["/users/1"] = .success((response, data))
let user = try await client.send(.getUser(id: "1"), as: User.self)
XCTAssertEqual(user.name, "Simran")
}
func testGetUser_401_refreshesToken() async throws {
// First call: 401
let unauthorizedResponse = HTTPURLResponse(url: URL(string: "https://api.example.com/users/1")!,
statusCode: 401, httpVersion: nil, headerFields: nil)!
MockURLProtocol.handlers["/users/1"] = .success((unauthorizedResponse, Data()))
// After refresh, should succeed
// ... setup mock to return 200 after refresh ...
}
func testVerifyRequestHeaders() async throws {
// Verify that requests include required headers
let _ = try? await client.send(.getUser(id: "1"), as: User.self)
let request = MockURLProtocol.requestLog.first
XCTAssertNotNil(request?.value(forHTTPHeaderField: "Authorization"))
XCTAssertNotNil(request?.value(forHTTPHeaderField: "X-Request-ID"))
}
}
URLSession ProtocolFor a cleaner dependency injection approach:
protocol URLSessionProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
// In tests:
class MockURLSession: URLSessionProtocol {
var stubbedResponse: (Data, URLResponse)?
var stubbedError: Error?
var lastRequest: URLRequest?
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lastRequest = request
if let error = stubbedError { throw error }
return stubbedResponse!
}
}
GraphQL requests are always POST to a single endpoint, with a JSON body containing the query:
struct GraphQLRequest: Encodable {
let query: String
let variables: [String: AnyEncodable]?
let operationName: String?
}
struct GraphQLResponse<T: Decodable>: Decodable {
let data: T?
let errors: [GraphQLError]?
struct GraphQLError: Decodable {
let message: String
let path: [String]?
let locations: [Location]?
struct Location: Decodable {
let line: Int
let column: Int
}
}
}
func query<T: Decodable>(_ query: String, variables: [String: Any] = [:]) async throws -> T {
let body = GraphQLRequest(
query: query,
variables: variables.mapValues { AnyEncodable($0) },
operationName: nil
)
var request = URLRequest(url: graphqlEndpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
let (data, _) = try await session.data(for: request)
let response = try JSONDecoder().decode(GraphQLResponse<T>.self, from: data)
if let errors = response.errors, !errors.isEmpty {
throw GraphQLError.queryErrors(errors)
}
guard let result = response.data else {
throw GraphQLError.noData
}
return result
}
// Usage — query is defined at the call site
let user: User = try await query("""
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts(limit: 10) {
id
title
}
}
}
""", variables: ["id": "user123"])
Protocol Buffers (protobuf) are a binary serialization format — much smaller and faster than JSON. gRPC is a high-performance RPC framework built on HTTP/2 and protobuf.
In iOS, use the swift-protobuf and grpc-swift packages:
// Add to Package.swift:
// .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.25.0")
// .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.21.0")
// Generated from .proto file by protoc:
// message User { string id = 1; string name = 2; string email = 3; }
// service UserService { rpc GetUser(GetUserRequest) returns (User); }
import GRPC
import NIO
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let channel = try GRPCChannelPool.with(
target: .host("api.example.com", port: 443),
transportSecurity: .tls(GRPCTLSConfiguration.makeClientDefault()),
eventLoopGroup: group
)
let client = UserService_UserServiceNIOClient(channel: channel)
// Unary RPC
var request = UserService_GetUserRequest()
request.id = "user123"
let user = try await client.getUser(request).response.get()
// Server streaming RPC
let call = client.streamUserEvents(request)
for try await event in call.responses {
handleEvent(event)
}
ATS enforces HTTPS by default for all network connections. Never disable it globally (NSAllowsArbitraryLoads = true) in production. If you need exceptions (for development servers, third-party SDKs), add targeted exceptions:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>dev.internal.company.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
URLs are logged — by URLSession debugging, by your analytics, by CDN access logs, by server logs. Never put sensitive data in URLs:
// BAD — API key in URL
let url = "https://api.example.com/data?api_key=secret123"
// GOOD — API key in header
request.setValue("secret123", forHTTPHeaderField: "X-API-Key")
// BAD — Token in URL (will appear in server logs, browser history, referrer headers)
let url = "https://api.example.com/download?token=jwt.token.here"
// GOOD — Token in Authorization header
request.setValue("Bearer jwt.token.here", forHTTPHeaderField: "Authorization")
// Ensure cookies use Secure and HttpOnly flags
// Check these server-side, but also validate client-side:
if let cookies = HTTPCookieStorage.shared.cookies(for: url) {
for cookie in cookies {
guard cookie.isSecure else {
// Log warning — production API should always set Secure flag
continue
}
}
}
// Create a sanitized debug description for requests
extension URLRequest {
var sanitizedDescription: String {
var headers = allHTTPHeaderFields ?? [:]
// Remove sensitive headers from logs
let sensitiveHeaders = ["Authorization", "X-API-Key", "Cookie", "Set-Cookie"]
sensitiveHeaders.forEach { headers[$0] = "[REDACTED]" }
return """
\(httpMethod ?? "GET") \(url?.absoluteString ?? "")
Headers: \(headers)
Body: \(httpBody.map { "\($0.count) bytes" } ?? "none")
"""
}
}
Always validate and sanitize user input before including it in network requests:
func buildSearchRequest(query: String) throws -> URLRequest {
// Validate input
guard !query.isEmpty, query.count <= 200 else {
throw ValidationError.invalidInput("Query must be 1-200 characters")
}
// URLComponents handles encoding — prevents URL injection
var components = URLComponents()
components.scheme = "https"
components.host = "api.example.com"
components.path = "/search"
components.queryItems = [URLQueryItem(name: "q", value: query)]
guard let url = components.url else { throw NetworkError.invalidURL }
return URLRequest(url: url)
}
Interceptors allow cross-cutting concerns (auth, logging, metrics, modification) to be applied to all requests without polluting business logic:
protocol RequestInterceptor {
func intercept(_ request: URLRequest) async throws -> URLRequest
}
protocol ResponseInterceptor {
func intercept(_ data: Data, _ response: URLResponse, for request: URLRequest) async throws -> (Data, URLResponse)
}
// Auth Interceptor — adds token to every request
struct AuthInterceptor: RequestInterceptor {
let tokenStore: TokenStore
func intercept(_ request: URLRequest) async throws -> URLRequest {
var mutableRequest = request
let token = try await tokenStore.validAccessToken()
mutableRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return mutableRequest
}
}
// Logging Interceptor
struct LoggingInterceptor: RequestInterceptor, ResponseInterceptor {
func intercept(_ request: URLRequest) async throws -> URLRequest {
let start = Date()
print("→ \(request.httpMethod ?? "GET") \(request.url?.path ?? "")")
return request
}
func intercept(_ data: Data, _ response: URLResponse, for request: URLRequest) async throws -> (Data, URLResponse) {
if let http = response as? HTTPURLResponse {
print("← \(http.statusCode) \(request.url?.path ?? "") [\(data.count) bytes]")
}
return (data, response)
}
}
// User-Agent Interceptor
struct UserAgentInterceptor: RequestInterceptor {
let userAgent: String
func intercept(_ request: URLRequest) async throws -> URLRequest {
var r = request
r.setValue(userAgent, forHTTPHeaderField: "User-Agent")
return r
}
}
// Pipeline executor
final class InterceptingNetworkClient {
private let session: URLSession
private let requestInterceptors: [RequestInterceptor]
private let responseInterceptors: [ResponseInterceptor]
init(session: URLSession, requestInterceptors: [RequestInterceptor], responseInterceptors: [ResponseInterceptor]) {
self.session = session
self.requestInterceptors = requestInterceptors
self.responseInterceptors = responseInterceptors
}
func execute(_ request: URLRequest) async throws -> (Data, URLResponse) {
// Apply request interceptors in order
var mutableRequest = request
for interceptor in requestInterceptors {
mutableRequest = try await interceptor.intercept(mutableRequest)
}
// Execute the request
var (data, response) = try await session.data(for: mutableRequest)
// Apply response interceptors in order
for interceptor in responseInterceptors {
(data, response) = try await interceptor.intercept(data, response, for: mutableRequest)
}
return (data, response)
}
}
// Setup
let client = InterceptingNetworkClient(
session: URLSession(configuration: .default),
requestInterceptors: [
UserAgentInterceptor(userAgent: "MyApp/2.1 iOS/17.0"),
AuthInterceptor(tokenStore: tokenStore),
LoggingInterceptor()
],
responseInterceptors: [
LoggingInterceptor()
]
)
Prevent the same request from being made simultaneously from different parts of the app (e.g., two views both requesting the current user at startup):
actor RequestDeduplicator {
private var inFlight: [String: Task<(Data, URLResponse), Error>] = [:]
func execute(key: String, operation: @escaping () async throws -> (Data, URLResponse)) async throws -> (Data, URLResponse) {
if let existing = inFlight[key] {
// Another caller is already fetching this — wait for their result
return try await existing.value
}
let task = Task<(Data, URLResponse), Error> {
defer { Task { await self.removeTask(key: key) } }
return try await operation()
}
inFlight[key] = task
return try await task.value
}
private func removeTask(key: String) {
inFlight.removeValue(forKey: key)
}
}
Queue requests made while offline and flush them when connectivity is restored:
actor OfflineQueue {
private var queue: [QueuedRequest] = []
private let persistence: RequestPersistence
struct QueuedRequest: Codable {
let id: UUID
let urlString: String
let method: String
let headers: [String: String]
let bodyBase64: String?
let enqueuedAt: Date
}
func enqueue(_ request: URLRequest) async throws {
let queued = QueuedRequest(
id: UUID(),
urlString: request.url!.absoluteString,
method: request.httpMethod ?? "GET",
headers: request.allHTTPHeaderFields ?? [:],
bodyBase64: request.httpBody?.base64EncodedString(),
enqueuedAt: Date()
)
queue.append(queued)
try await persistence.save(queue)
}
func flush(using session: URLSession) async {
let toFlush = queue
queue = []
await withTaskGroup(of: Void.self) { group in
for queued in toFlush {
group.addTask {
guard let url = URL(string: queued.urlString) else { return }
var request = URLRequest(url: url)
request.httpMethod = queued.method
request.allHTTPHeaderFields = queued.headers
if let b64 = queued.bodyBase64 {
request.httpBody = Data(base64Encoded: b64)
}
_ = try? await session.data(for: request)
}
}
}
}
}
| Code | Meaning | Retry? |
|---|---|---|
notConnectedToInternet |
No network | Yes, wait for connectivity |
networkConnectionLost |
Connection dropped mid-request | Yes |
timedOut |
Request exceeded timeout | Yes, with backoff |
cancelled |
Task was cancelled | No |
badServerResponse |
Invalid HTTP response | No |
cannotFindHost |
DNS failed | Maybe (transient DNS issues) |
secureConnectionFailed |
TLS failure | No (check cert) |
serverCertificateUntrusted |
Certificate rejected | No (check pinning) |
dataNotAllowed |
Cellular disabled in settings | No (user decision) |
| Header | Direction | Purpose |
|---|---|---|
Authorization |
Request | Auth credentials |
Content-Type |
Request/Response | Media type of body |
Accept |
Request | Desired response media types |
Accept-Encoding |
Request | Compression algorithms client supports |
Content-Encoding |
Response | Compression used on response body |
Cache-Control |
Both | Caching directives |
ETag |
Response | Resource version identifier |
If-None-Match |
Request | Conditional GET using ETag |
Last-Modified |
Response | Resource last modified time |
If-Modified-Since |
Request | Conditional GET using Last-Modified |
Retry-After |
Response | Delay before retrying (429, 503) |
X-Request-ID |
Request | Distributed tracing correlation ID |
Location |
Response | Redirect target or created resource URL |
This guide covers the full spectrum of iOS networking from foundational protocol knowledge to advanced production patterns. Master each section, and you'll be equipped to architect, implement, debug, and optimize any networking requirement at the senior level.