Setting up URLSession for network requests in iOS applications
URLSession is Apple's standard network stack, and most issues here don't come from API ignorance but from incorrect configuration: the default URLSession.shared works in small examples, but in production leads to memory leaks, App Transport Security violations and hard-to-diagnose timeouts.
Where mistakes happen most often
Incorrect URLSessionConfiguration. .shared doesn't support background-transfer and doesn't allow timeout configuration at the session level. For an API client you need at minimum:
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 60
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.urlCache = nil
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
delegateQueue: nil means URLSession creates its own serial queue. If you pass OperationQueue.main — all completion handlers run on main thread, which blocks UI during slow JSON parsing.
Ignoring URLSessionTaskDelegate when working with SSL pinning. Without a delegate, you can't override urlSession(_:didReceive:completionHandler:) to verify the certificate. I've seen countless applications where SSL-pinning is "implemented" through third-party libraries but doesn't actually work because the session was created without a delegate — a cycle preserved.
Leaks through [weak self]. URLSessionDataTask holds a strong reference to the session delegate until session.invalidateAndCancel() or finishTasksAndInvalidate() is called explicitly. If URLSession is stored as a class property and the class doesn't invalidate the session on deinit — the cycle persists.
How we build the network layer
Foundation — Protocol-Oriented approach with NetworkClient protocol, which allows mocking requests in Unit-tests without needing to run the server.
protocol NetworkClient {
func send<T: Decodable>(_ request: URLRequest) async throws -> T
}
final class URLSessionNetworkClient: NetworkClient {
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .init(configuration: .default)) {
self.session = session
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
func send<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await session.data(for: request)
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 decoder.decode(T.self, from: data)
}
}
Async/await instead of completion handlers — this is more than syntactic sugar. Structured concurrency allows canceling requests via Task.cancel(), which automatically calls task.cancel() at the URLSession level. The old completion-block scheme didn't provide this control.
Retry logic is implemented as a wrapper, not cluttering the main client:
func sendWithRetry<T: Decodable>(
_ request: URLRequest,
maxAttempts: Int = 3,
delay: Duration = .seconds(1)
) async throws -> T {
var lastError: Error
for attempt in 0..<maxAttempts {
do {
return try await send(request)
} catch NetworkError.httpError(let code, _) where code >= 500 {
lastError = NetworkError.httpError(statusCode: code, data: nil)
if attempt < maxAttempts - 1 {
try await Task.sleep(for: delay * Double(attempt + 1))
}
} catch {
throw error // don't retry 4xx and decoding errors
}
}
throw lastError
}
Background Downloads. For file downloads — URLSessionConfiguration.background(withIdentifier:). The system can terminate the process and resume the download on next launch. Required method in AppDelegate:
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
BackgroundDownloadManager.shared.completionHandler = completionHandler
}
Without this handler, iOS won't restart the application after background download completion.
Troubleshooting
Tools: Charles Proxy or Proxyman — for traffic inspection; Network Instrument in Xcode — for analyzing parallel connection counts and finding connection starvation; os_log with category com.apple.network — for low-level Network.framework logging.
On NSURLErrorDomain -1001 (request timed out) first check timeoutIntervalForRequest — default 60 seconds, which is unexpectedly long for a mobile app. On NSURLErrorDomain -1200 (SSL error) — check ATS policy in Info.plist and certificate chain correctness on the server via openssl s_client.
Process
Audit existing network layer: session configuration, error handling, timeouts, token authorization handling.
Design: API client with authorization support via URLSessionTaskDelegate or RequestInterceptor, handle 401 with token refresh.
Development: implementation, Unit-test coverage via mock session (URLProtocol subclass).
Testing: integration tests on real API, network behavior testing under poor conditions via Network Link Conditioner.
Timeline estimates
| Task | Timeline |
|---|---|
| Basic API client with async/await | 1 day |
| + SSL pinning + retry + token refresh | 2–3 days |
| Migrate existing network layer | 2–3 days |







