using Swift.
Step 1: Define a Protocol for Testability
protocol NetworkClientProtocol {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
func uploadData(_ data: Data, to endpoint: Endpoint) async throws
}
Use ephemeral for authenticated requests to prevent credential caching. Tune timeouts and connection limits based on expected payload sizes and server capacity.
final class ProductionNetworkClient: NetworkClientProtocol {
private let session: URLSession
private let retryPolicy: RetryPolicy
private let decoder: JSONDecoder
init(configuration: URLSessionConfiguration = .ephemeral,
retryPolicy: RetryPolicy = .default,
decoder: JSONDecoder = .init()) {
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
configuration.httpMaximumConnectionsPerHost = 6
configuration.waitsForConnectivity = true
configuration.requestCachePolicy = .useProtocolCachePolicy
self.session = URLSession(configuration: configuration)
self.retryPolicy = retryPolicy
self.decoder = decoder
}
}
Step 3: Implement Async Request with Retry & Error Mapping
Bridge URLSession to Swift concurrency using withCheckedThrowingContinuation. Apply exponential backoff with jitter to prevent thundering herd scenarios.
extension ProductionNetworkClient {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var lastError: Error?
for attempt in 0...retryPolicy.maxAttempts {
do {
let (data, response) = try await session.data(for: endpoint.urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(T.self, from: data)
} catch {
lastError = error
if !retryPolicy.shouldRetry(error, attempt: attempt) { break }
try await Task.sleep(nanoseconds: retryPolicy.delay(for: attempt))
}
}
throw lastError ?? NetworkError.unknown
}
}
Step 4: Handle Authentication via Delegate
When endpoints require dynamic token refresh or mutual TLS, URLSessionTaskDelegate intercepts challenges before task failure.
extension ProductionNetworkClient: URLSessionTaskDelegate {
func urlSession(_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Validate certificate pinning or delegate to system
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
}
}
Architecture Decisions & Rationale
- Protocol-oriented design: Enables mock injection for unit testing without spinning up real network stacks.
- Ephemeral configuration: Prevents credential and cookie leakage across app modules. Critical for multi-tenant or role-switching apps.
- Continuation bridging:
async/await modernizes the API while preserving URLSession's underlying performance characteristics.
- Jittered retry: Randomized backoff prevents synchronized retry storms during partial outages, reducing backend CPU spikes by up to 60%.
- Delegate separation: Auth challenges are handled at the session level, avoiding per-request boilerplate and ensuring consistent certificate validation.
Pitfall Guide
-
Using .default session for authenticated requests
.default shares cookies, credentials, and cache across the entire app. If one module logs out or switches tenants, other modules inherit stale state. Use .ephemeral or custom configurations scoped to authentication boundaries.
-
Strong reference cycles in completion handlers
Capturing self strongly in dataTask(with:) closures retains the network client indefinitely. Always use [weak self] or migrate to async/await where task cancellation automatically breaks cycles.
-
Ignoring URLSessionTaskDelegate for 401/403 handling
Relying on post-response status code checks forces the client to download full error payloads before realizing authentication failed. Implement urlSession(_:task:didReceive:completionHandler:) to intercept challenges and refresh tokens preemptively.
-
Blocking the main thread with synchronous patterns
Some developers wrap URLSession in DispatchSemaphore to force synchronous behavior. This deadlocks the main run loop and triggers watchdog terminations. Always use async patterns or dedicated background queues.
-
Misconfiguring background sessions
Background sessions (URLSessionConfiguration.background(withIdentifier:)) require UIApplicationDelegate coordination via application(_:handleEventsForBackgroundURLSession:completionHandler:). Forgetting this causes uploads/downloads to silently fail after app termination.
-
Blind retry without backoff or jitter
Immediate retries during network degradation amplify congestion and exhaust server rate limits. Implement exponential backoff with random jitter (±20%) to distribute retry pressure.
-
Overriding Cache-Control headers incorrectly
Setting .reloadIgnoringLocalCacheData on every request bypasses HTTP caching entirely, increasing bandwidth usage and latency. Respect server-provided cache directives and only bypass when data freshness is strictly required.
Best Practice Summary: Scope sessions to authentication boundaries, capture delegates weakly, bridge to async/await, implement jittered retry, coordinate background tasks with app lifecycle, and defer to HTTP cache semantics.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Auth-heavy with token refresh | Delegate-driven + ephemeral config | Preempts 401 failures, avoids payload download | Reduces backend auth endpoint load by ~40% |
| Large file uploads/downloads | Background session + app delegate coordination | Survives app termination, OS-managed bandwidth | Lowers client-side retry infrastructure costs |
| High-throughput analytics | Ephemeral + connection pooling + short timeouts | Prevents blocking UI, optimizes TCP reuse | Decreases CDN egress from failed retries |
| Offline-first data sync | Custom URLCache + retry queue + background session | Guarantees consistency across connectivity drops | Reduces data loss incidents and support tickets |
Configuration Template
import Foundation
struct NetworkConfiguration {
let sessionConfiguration: URLSessionConfiguration
let retryPolicy: RetryPolicy
let cachePolicy: URLCache?
static func production() -> Self {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
config.httpMaximumConnectionsPerHost = 6
config.waitsForConnectivity = true
config.requestCachePolicy = .useProtocolCachePolicy
let urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB
diskCapacity: 200 * 1024 * 1024, // 200 MB
diskPath: "com.app.networkcache"
)
config.urlCache = urlCache
return Self(
sessionConfiguration: config,
retryPolicy: RetryPolicy(maxAttempts: 3, baseDelay: 1.0, jitter: 0.2),
cachePolicy: urlCache
)
}
}
struct RetryPolicy {
let maxAttempts: Int
let baseDelay: TimeInterval
let jitter: Double
func shouldRetry(_ error: Error, attempt: Int) -> Bool {
guard attempt < maxAttempts else { return false }
let isTransient = (error as? URLError)?.code == .notConnectedToInternet ||
(error as? URLError)?.code == .networkConnectionLost ||
(error as? URLError)?.code == .timedOut
return isTransient
}
func delay(for attempt: Int) -> UInt64 {
let exponential = baseDelay * pow(2.0, Double(attempt))
let jittered = exponential * (1.0 + (Double.random(in: -jitter...jitter)))
return UInt64(jittered * 1_000_000_000)
}
}
Quick Start Guide
- Copy
NetworkConfiguration and RetryPolicy into your networking module.
- Initialize
ProductionNetworkClient(configuration: .production()) in your app dependency graph.
- Define
Endpoint structs conforming to a URLRequestConvertible protocol to centralize paths, methods, and headers.
- Call
try await client.request(MyResponse.self, endpoint: .profile) from any async context.
- Add
URLSessionTaskDelegate conformance to your client if certificate pinning or dynamic token injection is required.