🌙
☀️ Dark
PART 9

Networking

URLSession, Codable, async API, caching, retries, and offline behavior.

Intermediate 45 min read

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:

  1. The OS checks if a connection to the host is already open (Connection Pooling).
  2. If not, it resolves the DNS, performs a TCP handshake, and executes a TLS handshake for HTTPS.
  3. The request headers and body are serialized and sent over the socket.
  4. The calling Swift `Task` is suspended, freeing up the CPU thread for other work.
  5. The OS receives the HTTP response, reconstructs the packets into a data buffer.
  6. 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
swift
swift
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
swift
swift
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.