Networking
URLSession, Codable, async API, caching, retries, and offline behavior.
Part 9 — Networking
Learning Objectives
By the end of this chapter, you will understand how modern iOS applications communicate with the world. You will be able to build robust, scalable, and secure networking layers using URLSession, handle JSON serialization with Codable, manage authentication, implement retries, handle pagination, and architect your application to gracefully survive network failures and offline scenarios.
Prerequisites
You must understand:
- Swift concurrency (async/await, Tasks)
- Error handling in Swift
- Value vs Reference semantics
- Generics
Why Does This Exist?
The Problem Before the Solution
In the early days of iOS, networking was performed using NSURLConnection, which relied heavily on delegates. Developers had to manually manage connection state, concatenate received data chunks, parse untyped dictionaries (often using third-party libraries for JSON), and manage callback hell to execute UI updates on the main thread.
// The old, fragile way (Conceptual)
var receivedData = NSMutableData()
func connection(_ connection: NSURLConnection, didReceive data: Data) {
receivedData.append(data)
}
func connectionDidFinishLoading(_ connection: NSURLConnection) {
// Parse JSON manually from receivedData
}
Why the Old Approach Breaks
The old approach broke for several reasons:
- Complexity: Managing raw data chunks and connection states manually is error-prone.
- Callback Hell: Nested closures or sprawling delegate methods make reasoning about control flow difficult.
- Correctness: Untyped JSON parsing leads to runtime crashes.
- Maintainability: Boilerplate code scattered across view controllers makes the codebase fragile.
History
Apple introduced `URLSession` to replace `NSURLConnection`, providing a modern, block-based (and later async/await) API that handles connection pooling, HTTP/2 multiplexing, and background transfers. Later, Swift introduced `Codable`, eliminating manual JSON serialization. Most recently, Swift Concurrency (`async/await`) was added to `URLSession`, allowing asynchronous network requests to be written as straight-line code without callbacks.
Mental Model
Internal Working
When you call `URLSession.shared.data(for: request)`, the following occurs beneath the surface:
- The OS checks if a connection to the host is already open (Connection Pooling).
- If not, it resolves the DNS, performs a TCP handshake, and executes a TLS handshake for HTTPS.
- The request headers and body are serialized and sent over the socket.
- The calling Swift `Task` is suspended, freeing up the CPU thread for other work.
- The OS receives the HTTP response, reconstructs the packets into a data buffer.
- The Swift `Task` is resumed, returning the `Data` and `URLResponse`.
Visual Explanation
URLRequest (URL, Headers, Method)
↓
URLSession (Connection Pool, TLS, TCP)
↓
Network (Internet)
↓
Server (Processes Request)
↓
Network (Internet)
↓
URLSession (Receives bytes, parses HTTP)
↓
Swift Task Resumes (Data, URLResponse)
↓
JSONDecoder (Parses Data to Model)
Syntax
let url = URL(string: "https://api.example.com/data")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
Tiny Example
struct User: Codable {
let id: Int
let name: String
}
func fetchUser() async throws -> User {
let url = URL(string: "https://jsonplaceholder.typicode.com/users/1")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
Walkthrough
In `fetchUser()`: We construct a `URL`. We pass it to `URLSession.shared.data(from:)`, prepended with `try await`. The function suspends while the network request flies. When it returns, we get `Data`. We then use `JSONDecoder().decode()` to safely and statically map the raw JSON bytes into our strongly-typed `User` struct.
Break It
// Intentional Bug: Assuming the response is always a success
func fetchUserBroken() async throws -> User {
let url = URL(string: "https://api.example.com/broken-endpoint")!
let (data, _) = try await URLSession.shared.data(from: url)
// CRASH: What if the server returned a 404 HTML page instead of JSON?
return try JSONDecoder().decode(User.self, from: data)
}
Debug It
When debugging network issues, don't just stare at the JSON decoder error. Inspect the `URLResponse`. The server might be returning a 401 Unauthorized or 500 Internal Server Error. Always cast the response to `HTTPURLResponse` and check the `statusCode` (e.g., `200...299`). Tools like Charles Proxy or Proxyman are invaluable for inspecting the raw HTTP traffic leaving the device.
Real Application Feature
A resilient network layer that handles authentication tokens, automatic retries for transient errors, and timeouts.
Production Implementation
enum NetworkError: Error {
case invalidResponse
case httpError(statusCode: Int)
}
class APIClient {
let session: URLSession
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
}
func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
var retries = 3
while retries > 0 {
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
} catch {
retries -= 1
if retries == 0 { throw error }
try await Task.sleep(nanoseconds: 1_000_000_000) // Exponential backoff in real apps
}
}
fatalError("Unreachable")
}
}
Production Usage
In production applications (like banking apps or social networks), networking logic is abstracted behind an API client or repository. Direct `URLSession` calls are never made from Views. The network layer handles token refresh logic interceptors, SSL pinning for security, and logging.
Performance
Opening TCP connections is expensive. `URLSession` automatically pools and reuses connections to the same host. Avoid creating new `URLSession` instances repeatedly; use a shared or singleton instance for the same configuration to benefit from HTTP/2 multiplexing and connection pooling.
Best Practices
- Never put network calls in a View.
- Always check the HTTP status code.
- Use `Codable` for parsing.
- Implement timeouts and retries.
- Handle offline states gracefully (e.g., show cached data).
Engineering Challenge
Design a system that downloads a 5GB video file in the background, surviving app termination by the OS, and reporting progress to the UI when the app is relaunched.
Solution Hints
Use `URLSessionConfiguration.background(withIdentifier:)`. Implement the `URLSessionDownloadDelegate`. The OS daemon handles the download and wakes up the app when finished.
Revision Sheet
- URLSession handles networking.
- Codable handles parsing.
- Check HTTP status codes before parsing.
- Abstract networking logic out of the UI.
- Respect offline states and timeouts.
Connections
Connects heavily to Swift Concurrency (Task, async/await) for execution, and Architecture (Dependency Injection) for mocking network calls during Testing.
Mini Project (20-30 min)
Use `URLSession` and async/await to fetch data from the JSONPlaceholder API (e.g., `/todos/1`) and decode it into a Swift struct using `Codable`.
View Solution
import Foundation
struct Todo: Codable {
let userId: Int
let id: Int
let title: String
let completed: Bool
}
func fetchTodo() async throws -> Todo {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
// Perform network request
let (data, response) = try await URLSession.shared.data(from: url)
// Check HTTP status code
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
// Decode JSON
let decoder = JSONDecoder()
let todo = try decoder.decode(Todo.self, from: data)
return todo
}
// Usage in an async context:
// let todo = try await fetchTodo()
// print(todo.title)
Bigger Project (1-2 hours)
Build a generic Network Manager that can handle any `Codable` type and supports different HTTP methods (GET, POST). Create a custom Error enum to handle various networking failures gracefully.
View Solution
import Foundation
enum NetworkError: Error {
case invalidURL
case requestFailed(statusCode: Int)
case decodingFailed(Error)
case unknown(Error)
}
enum HTTPMethod: String {
case GET, POST, PUT, DELETE
}
class NetworkManager {
static let shared = NetworkManager()
private init() {}
func request(
urlString: String,
method: HTTPMethod = .GET,
body: Data? = nil
) async throws -> T {
guard let url = URL(string: urlString) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
if let body = body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
throw NetworkError.requestFailed(statusCode: httpResponse.statusCode)
}
do {
let decodedData = try JSONDecoder().decode(T.self, from: data)
return decodedData
} catch {
throw NetworkError.decodingFailed(error)
}
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.unknown(error)
}
}
}
Interview Questions
Easy: What is the `Codable` protocol?
`Codable` is a type alias for the `Encodable` and `Decodable` protocols. It allows Swift models to be seamlessly converted to and from external data representations like JSON.
Medium: What is the main difference between URLSession tasks (DataTask, DownloadTask, UploadTask)?
DataTask is used for short bursts of data (like fetching JSON). DownloadTask saves the response directly to a file on disk (great for large files). UploadTask is optimized for sending files or large amounts of data in the request body.
Hard: How do you handle SSL Pinning in URLSession?
SSL Pinning is handled by implementing the `URLSessionDelegate` method `urlSession(_:didReceive:completionHandler:)`. Inside, you verify the server's certificate or public key presented in the `URLAuthenticationChallenge` against a locally stored copy of the expected certificate/key. If they match, you allow the connection; otherwise, you cancel it to prevent man-in-the-middle attacks.