4.8 — Networking
Opening scenario
Backend gives you a REST API. Your app needs to:
- Fetch the user’s feed (paginated, with auth header)
- POST a new comment
- Upload a 5MB image with progress
- Stream a download in the background while the user uses the app
- Retry on flaky connectivity
- Cache the feed for offline viewing
- Cancel in-flight requests when the user navigates away
You don’t write any of that on top of raw BSDSockets. You use URLSession with async/await — Apple’s modern networking layer. This chapter is the working knowledge required for production iOS networking.
| Need | Tool |
|---|---|
| One-off GET/POST | URLSession.shared.data(for:) |
| Custom timeouts, cellular policy, auth handling | URLSession(configuration:) + delegate |
| Large download with progress | URLSession.downloadTask |
| Background download/upload | URLSessionConfiguration.background(...) |
| WebSocket | URLSessionWebSocketTask |
| Reactive streams | Combine or AsyncSequence |
| GraphQL | Apollo or custom on top of URLSession |
Concept → Why → How → Code
URLSession — the foundation
import Foundation
let url = URL(string: "https://api.example.com/feed")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw NetworkError.badResponse
}
let feed = try JSONDecoder().decode(Feed.self, from: data)
Three task types:
- Data tasks — memory-buffered request/response (most common)
- Upload tasks — POST/PUT a body from
Data,URL, orInputStream - Download tasks — write response to a file on disk (large payloads, background)
A real networking client
In production you wrap URLSession in a thin service that handles auth, encoding, error mapping:
struct APIRequest<Response: Decodable> {
let path: String
let method: HTTPMethod
let body: Encodable?
let queryItems: [URLQueryItem]
}
enum HTTPMethod: String { case GET, POST, PUT, DELETE, PATCH }
enum NetworkError: Error {
case invalidURL
case http(Int, Data)
case decoding(Error)
case transport(Error)
case unauthorized
}
final class APIClient {
private let session: URLSession
private let baseURL: URL
private let tokenProvider: () async -> String?
init(baseURL: URL,
session: URLSession = .shared,
tokenProvider: @escaping () async -> String?) {
self.baseURL = baseURL
self.session = session
self.tokenProvider = tokenProvider
}
func send<R: Decodable>(_ request: APIRequest<R>) async throws -> R {
var components = URLComponents(url: baseURL.appendingPathComponent(request.path),
resolvingAgainstBaseURL: false)!
if !request.queryItems.isEmpty { components.queryItems = request.queryItems }
guard let url = components.url else { throw NetworkError.invalidURL }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = await tokenProvider() {
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = request.body {
urlRequest.httpBody = try JSONEncoder().encode(AnyEncodable(body))
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: urlRequest)
} catch {
throw NetworkError.transport(error)
}
let http = response as! HTTPURLResponse
switch http.statusCode {
case 200..<300:
do { return try JSONDecoder().decode(R.self, from: data) }
catch { throw NetworkError.decoding(error) }
case 401:
throw NetworkError.unauthorized
default:
throw NetworkError.http(http.statusCode, data)
}
}
}
// AnyEncodable helper for Encodable existential
struct AnyEncodable: Encodable {
let value: Encodable
init(_ v: Encodable) { self.value = v }
func encode(to encoder: Encoder) throws { try value.encode(to: encoder) }
}
Usage:
let req = APIRequest<Feed>(path: "/feed", method: .GET, body: nil, queryItems: [])
let feed = try await api.send(req)
Benefits: typed request/response, central auth/headers, central error handling, easy to mock for tests.
Cancellation
In Swift Concurrency, cancellation propagates through Task:
let task = Task {
let feed = try await api.send(feedRequest)
await MainActor.run { self.show(feed) }
}
// User navigates away
task.cancel()
URLSession honors cancellation: when the Task is cancelled, the underlying URLSessionDataTask is cancelled, and the await throws CancellationError or URLError(.cancelled).
In a UIViewController, cancel ongoing work in viewDidDisappear or when initiating new work:
private var loadTask: Task<Void, Never>?
private func reload() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
do {
let feed = try await api.send(feedRequest)
try Task.checkCancellation()
self.show(feed)
} catch is CancellationError {
return
} catch {
self.showError(error)
}
}
}
Upload with progress
let url = URL(string: "https://api.example.com/photo")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
let (asyncBytes, response) = try await URLSession.shared.upload(for: req, from: jpegData, delegate: self)
// To observe progress, attach a URLSessionTaskDelegate:
class ProgressDelegate: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
// post to UI
}
}
The iOS 15+ upload(for:from:delegate:) ties the per-call delegate. For richer per-task progress, use URLSessionUploadTask directly with task.progress observable via KVO or task.progress.fractionCompleted.
Background sessions
For uploads/downloads that must continue when your app suspends:
let config = URLSessionConfiguration.background(withIdentifier: "com.myapp.upload")
config.isDiscretionary = false // true lets iOS schedule based on power/wifi
config.sessionSendsLaunchEvents = true
let bgSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = bgSession.uploadTask(with: req, fromFile: fileURL)
task.resume()
When the upload completes (even if the app was killed), iOS launches your app in the background and calls application(_:handleEventsForBackgroundURLSession:completionHandler:). Implement the delegate to finalize.
Constraints:
- Only file-based (
uploadTask(with:fromFile:)) — noDatapayloads - One session per identifier; recreate on app launch with the same identifier to reattach
- Test on a real device; simulator skips some background behavior
Caching
URLSession honors HTTP caching headers (Cache-Control, ETag, Last-Modified) via URLCache:
let config = URLSessionConfiguration.default
config.urlCache = URLCache(memoryCapacity: 10 * 1024 * 1024,
diskCapacity: 100 * 1024 * 1024,
directory: nil)
config.requestCachePolicy = .useProtocolCachePolicy
let session = URLSession(configuration: config)
For app-level cache (parsed objects, image bitmaps), use NSCache:
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
cache.totalCostLimit = 50 * 1024 * 1024 // ~50MB
For images specifically use a library (Nuke, Kingfisher, SDWebImage) — they handle decoding off the main thread, downsampling for cell size, prefetching, and disk cache.
Retry & exponential backoff
func sendWithRetry<R: Decodable>(_ request: APIRequest<R>, attempts: Int = 3) async throws -> R {
var lastError: Error?
for attempt in 0..<attempts {
do {
return try await send(request)
} catch NetworkError.http(let code, _) where (500...599).contains(code) {
lastError = NetworkError.http(code, Data())
} catch NetworkError.transport {
lastError = NetworkError.transport(URLError(.networkConnectionLost))
}
// exponential backoff with jitter
let delay = pow(2.0, Double(attempt)) * 0.5 + Double.random(in: 0..<0.5)
try await Task.sleep(for: .seconds(delay))
}
throw lastError ?? NetworkError.transport(URLError(.unknown))
}
Don’t retry on 4xx (client errors won’t fix themselves on retry). Do retry on 5xx, transient transport errors, timeouts.
Auth: token refresh
Common pattern: access token expires; refresh once, retry the original request. Race condition: 5 in-flight requests all 401 simultaneously and all try to refresh.
actor TokenStore {
private var accessToken: String?
private var refreshTask: Task<String, Error>?
func currentToken() async throws -> String {
if let accessToken { return accessToken }
return try await refresh()
}
func refresh() async throws -> String {
if let existing = refreshTask { return try await existing.value }
let task = Task<String, Error> {
let newToken = try await performRefresh()
self.accessToken = newToken
self.refreshTask = nil
return newToken
}
self.refreshTask = task
return try await task.value
}
func invalidate() { accessToken = nil }
}
actor serializes access. Concurrent callers see the same in-flight refresh task and await its result.
Pagination
Cursor-based (preferred over page numbers):
struct FeedPage: Decodable {
let items: [Article]
let nextCursor: String?
}
func loadNext() async {
let req = APIRequest<FeedPage>(
path: "/feed",
method: .GET,
body: nil,
queryItems: nextCursor.map { [URLQueryItem(name: "cursor", value: $0)] } ?? []
)
let page = try await api.send(req)
articles.append(contentsOf: page.items)
nextCursor = page.nextCursor
}
In UICollectionView, trigger loadNext from prefetchItemsAt when the user nears the bottom of loaded content.
WebSockets
let task = URLSession.shared.webSocketTask(with: URL(string: "wss://api.example.com/live")!)
task.resume()
Task {
while true {
let message = try await task.receive()
switch message {
case .string(let text): handle(text)
case .data(let data): handle(data)
@unknown default: break
}
}
}
// Send
try await task.send(.string("hello"))
// Close
task.cancel(with: .goingAway, reason: nil)
For long-lived connections, implement ping/pong heartbeats (task.sendPing(pongReceiveHandler:)) and reconnection with backoff.
Network monitoring
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
print("Online")
if path.usesInterfaceType(.cellular) { print("Cellular") }
} else {
print("Offline")
}
}
monitor.start(queue: .global())
Use to gate “Retry” buttons, show offline banners, defer non-urgent uploads to Wi-Fi.
Security: ATS, pinning
App Transport Security (ATS) requires HTTPS with modern TLS. Exceptions go in Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/> <!-- never set true in production -->
<key>NSExceptionDomains</key>
<dict>
<key>legacy.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
For high-security apps (banking, healthcare), pin certificates via URLSessionDelegate.urlSession(_:didReceive:completionHandler:). Validate the server’s certificate chain against bundled pinned hashes. Reject otherwise. Prevents MITM with rogue CAs.
Testing
Don’t hit the network in tests. Inject a mock session:
final class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
// In test
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
let api = APIClient(baseURL: URL(string: "https://test")!, session: session, tokenProvider: { "test" })
MockURLProtocol.requestHandler = { req in
let response = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = #" {"items":[]} "#.data(using: .utf8)!
return (response, data)
}
In the wild
- Slack iOS uses
URLSessionfor REST + WebSockets for real-time. Background session for file uploads. - Lyft custom client on top of
URLSessionwith circuit-breaker pattern (after N failures, stop hitting endpoint for a window). - Apollo iOS (used by Airbnb, Robinhood) wraps
URLSessionfor GraphQL with response caching and normalized cache (each entity stored once, queries reference it). - Nuke and Kingfisher are the standard image-loading libraries, both on top of
URLSessionwith custom in-memory and disk caching. - Apple News uses background URL sessions to pre-fetch articles overnight on Wi-Fi.
Common misconceptions
- “Use
AlamofirebecauseURLSessionis too low-level.” In 2026, with async/await,URLSessionis less code than Alamofire for typical use. Reach for libraries when you have a real need (request adapting, advanced retry, GraphQL). - “Set
requestCachePolicy = .reloadIgnoringCacheDatato be safe.” That defeats HTTP caching. Default.useProtocolCachePolicyis correct most of the time. - “
URLSession.sharedis fine for everything.” Fine for one-off GETs. For auth, custom config, background, or testing — instantiate your own. - “Retry every error with exponential backoff.” Don’t retry 4xx (they won’t change), don’t retry POST with non-idempotent body (you’ll double-submit). Retry GET, idempotent PUT, and transient 5xx/timeouts.
- “async/await means I don’t need delegates.” Wrong. Per-task delegates (
URLSession.data(for:delegate:)) are how you observe progress, handle auth challenges, and customize per-request behavior.
Seasoned engineer’s take
Networking code is where bugs hide because the network is non-deterministic. Habits:
- Centralize. One
APIClient(or generated client from OpenAPI/GraphQL schema). Don’t sprinkleURLSession.shared.dataacross view controllers. - Type the responses end-to-end.
Decodablemodels,Resultorthrowspropagation, no[String: Any]JSON dictionaries floating around. - Profile real networks. Use Xcode’s Network Link Conditioner (“3G”, “Edge”, “100% Loss”) regularly. Your loading states and timeouts are wrong if you only test on Wi-Fi.
- Treat errors as first-class UI. Every network call has a loading, success, empty, and error state. Sketch all four for every screen.
- Log responsibly. Don’t log auth tokens or PII. Use OSLog with privacy markers (
"\(token, privacy: .private)").
TIP: When debugging “why is this request failing in production but not in the simulator,” check (1) certificate pinning if you have it, (2) network reachability vs DNS issues, (3) clock skew (some servers reject requests with timestamps off by >5min), (4) proxy / VPN configurations on the user’s device.
WARNING: Using
URLSession.sharedwith a background-session identifier is a programming error and will crash. Background sessions must be created withURLSession(configuration:delegate:delegateQueue:).
Interview corner
Junior-level: “How do you fetch JSON from an endpoint?”
let (data, _) = try await URLSession.shared.data(from: url)
let result = try JSONDecoder().decode(Model.self, from: data)
Wrap in do/catch, handle network errors and decoding errors separately, present the right UI.
Mid-level: “How would you handle an expired auth token mid-request?”
API client checks for 401 in response. Calls a TokenStore actor to refresh; multiple concurrent requests share one refresh Task to avoid stampede. Once refreshed, retry the original request once. If refresh also 401s, log out user.
Senior-level: “Design an offline-first feed: fetch from network, cache locally, show cache instantly, refresh in background, handle conflicts.”
FeedRepository exposes an AsyncStream<[Article]> for observers. On subscription, emits cache immediately (from Core Data or disk). Kicks off network fetch in background. On success, merges into cache (last-write-wins per article ID with updatedAt comparison) and emits new state. Network failures keep cache. Pagination via cursor; “load more” appends. Pull-to-refresh re-fetches first page. WebSocket subscription pushes deltas; on delta, update cache, emit. Conflict UI for edits made offline that conflict with server changes — Notes-style “keep local”/“keep server” prompt. Tested with XCTestExpectation + mock URLSession; race condition tests with TaskGroup.
Red flag in candidates: Using completion handlers in new code in 2026. Async/await is the default for networking; completion-handler patterns belong in legacy contexts only.
Lab preview
Lab 4.1 builds a real news reader: URLSession async/await, Codable, error states, pull-to-refresh.
Next: 4.9 — Background tasks