APIs & Data Management

Chapter 4
Networking with URLSession

Certificate in IOS Development
APIs & Data Management   •   Certificate

Networking with URLSession

Introduction

Most iOS applications depend on network services. A weather application retrieves forecasts, a banking application obtains account information, and a social application uploads photographs and downloads messages. Networking is therefore not an optional feature of modern mobile development; it is a boundary between the application and an external system that may be slow, unavailable, incorrectly configured, or returning unexpected data.

Apple’s URLSession framework provides the principal high-level API for HTTP and HTTPS communication in iOS. It supports ordinary requests, uploads, downloads, authentication, caching, background transfers, cancellation, and asynchronous programming. This chapter develops a practical model for constructing requests with URLRequest, executing them with URLSession, decoding JSON responses with Codable, transferring files, and updating the user interface safely.

Learning Objectives

By the end of this chapter, you should be able to:

  • distinguish a URL, an HTTP request, a response, and a session;
  • construct GET, POST, and other requests using URLRequest;
  • execute requests with async/await;
  • validate HTTP status codes and decode JSON safely;
  • upload data and download files;
  • handle cancellation, timeouts, connectivity failures, and malformed responses;
  • update SwiftUI or UIKit user interfaces on the main actor;
  • apply security, privacy, and maintainability practices to network code.

The Network Request Model

A network exchange has four important parts:

  1. URL — identifies the resource, including scheme, host, path, and possibly query items.
  2. Request — adds an HTTP method, headers, body, and policies such as cache behavior.
  3. Response — contains status information, headers, and a payload.
  4. Session — manages connections, authentication, caching, cookies, configuration, and transfer tasks.

For example, a request to https://api.example.com/books?limit=20 can be interpreted as follows:

ComponentMeaning
httpsSecure transport scheme
api.example.comServer host
/booksResource path
limit=20Query parameter

HTTP methods communicate intent. GET retrieves data, POST commonly creates a resource or submits an operation, PUT replaces a resource, PATCH partially modifies one, and DELETE removes one. The meaning is ultimately defined by the API contract.

A successful transport operation does not necessarily mean a successful application operation. A server may return HTTP 404 or 500 while the request itself was transmitted correctly. Consequently, robust code checks both transport errors and HTTP status codes.

Constructing URLRequest Values

URLRequest is a mutable value that describes an HTTP request. It is preferable to construct URLs with URLComponents rather than concatenating strings, because query values must be correctly percent-encoded.

import Foundation

var components = URLComponents()
components.scheme = "https"
components.host = "api.example.com"
components.path = "/books"
components.queryItems = [
    URLQueryItem(name: "limit", value: "20"),
    URLQueryItem(name: "category", value: "ios development")
]

guard let url = components.url else {
    throw URLError(.badURL)
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 20

A request body is normally used with methods such as POST or PATCH. When sending JSON, the Content-Type header identifies the representation and JSONEncoder performs serialization.

struct CreateBook: Encodable {
    let title: String
    let author: String
}

let newBook = CreateBook(title: "Networking on iOS", author: "A. Rivera")
var postRequest = URLRequest(url: URL(string: "https://api.example.com/books")!)
postRequest.httpMethod = "POST"
postRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
postRequest.setValue("application/json", forHTTPHeaderField: "Accept")
postRequest.httpBody = try JSONEncoder().encode(newBook)

In production code, avoid force-unwrapping URLs unless the value is a compile-time constant that has been independently verified. A malformed endpoint should produce a controlled error rather than terminate the application.

URLSession and async/await

The simplest modern request uses URLSession.shared.data(for:). The method suspends the current asynchronous task while the system performs the transfer; it does not block the calling thread. When the operation completes, it returns (Data, URLResponse) or throws an error.

func loadBooks() async throws -> [Book] {
    let url = URL(string: "https://api.example.com/books")!
    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let httpResponse = response as? HTTPURLResponse,
          200..<300 ~= httpResponse.statusCode else {
        throw NetworkError.invalidHTTPResponse
    }

    return try JSONDecoder().decode([Book].self, from: data)
}

A useful error model separates URL construction, transport, server, and decoding failures.

enum NetworkError: LocalizedError {
    case invalidHTTPResponse
    case server(statusCode: Int)
    case emptyResponse

    var errorDescription: String? {
        switch self {
        case .invalidHTTPResponse:
            return "The server returned an invalid response."
        case .server(let statusCode):
            return "The server returned status code \(statusCode)."
        case .emptyResponse:
            return "The response contained no data."
        }
    }
}

A more informative validation function preserves the status code:

func validate(_ response: URLResponse) throws {
    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.invalidHTTPResponse
    }
    guard 200..<300 ~= http.statusCode else {
        throw NetworkError.server(statusCode: http.statusCode)
    }
}

The expression 200..<300 ~= statusCode tests whether the status code lies in the conventional successful range. APIs may define additional successful codes, so the client should follow the service documentation.

Decoding JSON with Codable

Codable combines Encodable and Decodable. A model should represent the documented response rather than merely reproducing fields that happen to be present in one sample.

struct Book: Decodable, Identifiable {
    let id: Int
    let title: String
    let authorName: String

    enum CodingKeys: String, CodingKey {
        case id
        case title
        case authorName = "author_name"
    }
}

The decoder converts bytes into typed values, but decoding can fail because of an unexpected key type, missing required field, invalid date, or changed server schema. Date handling must be configured to match the API:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let books = try decoder.decode([Book].self, from: data)

Do not treat decoding failure as proof that the user has no data. It indicates that the client could not interpret the response and should be logged, reported, or shown through an appropriate error state.

A Reusable API Client

Centralizing networking reduces duplicated validation and makes testing easier.

struct APIClient {
    let session: URLSession
    let baseURL: URL

    init(session: URLSession = .shared,
         baseURL: URL = URL(string: "https://api.example.com")!) {
        self.session = session
        self.baseURL = baseURL
    }

    func get<T: Decodable>(_ path: String, as type: T.Type) async throws -> T {
        let url = baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        let (data, response) = try await session.data(for: request)
        try validate(response)
        return try JSONDecoder().decode(T.self, from: data)
    }
}

Dependency injection of URLSession is important. Tests can use a session configured with a custom URLProtocol, allowing deterministic responses without contacting a real server.

Updating the User Interface Safely

UI frameworks require UI state changes on the main actor. In SwiftUI, an observable model can be isolated with @MainActor:

@MainActor
final class BooksViewModel: ObservableObject {
    @Published private(set) var books: [Book] = []
    @Published private(set) var isLoading = false
    @Published var errorMessage: String?

    private let client = APIClient()

    func refresh() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            books = try await client.get("books", as: [Book].self)
        } catch is CancellationError {
            // Cancellation is normally not presented as a failure.
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

A SwiftUI view may start and cancel work according to its lifecycle:

struct BooksView: View {
    @StateObject private var model = BooksViewModel()

    var body: some View {
        List(model.books) { book in
            VStack(alignment: .leading) {
                Text(book.title).font(.headline)
                Text(book.authorName)
            }
        }
        .overlay {
            if model.isLoading { ProgressView() }
        }
        .task {
            await model.refresh()
        }
        .refreshable {
            await model.refresh()
        }
    }
}

URLSession work is asynchronous, but asynchronous does not mean “automatically safe.” Shared mutable state still needs actor isolation or another synchronization strategy. The view model owns presentation state, while the API client owns communication concerns.

Uploading Data

For a small JSON or binary payload, upload(for:from:) is concise:

func createBook(_ book: CreateBook) async throws -> Book {
    let url = URL(string: "https://api.example.com/books")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    let body = try JSONEncoder().encode(book)
    let (data, response) = try await URLSession.shared.upload(
        for: request, from: body
    )
    try validate(response)
    return try JSONDecoder().decode(Book.self, from: data)
}

Multipart form data is commonly used for files and form fields. Its body contains boundaries, headers, and binary content. The boundary must be unique and must appear consistently in both the Content-Type header and body. For large or repeated uploads, a dedicated multipart builder or a well-tested library can reduce implementation errors.

Never place passwords, API keys, or personally identifying data in URLs when the server offers a safer header or body-based mechanism. URLs can appear in logs, browser history, proxy records, or analytics systems.

Downloading Data and Files

data(for:) is appropriate when the response is modest and will be processed in memory. For a file, download(for:) writes the result to a temporary location and returns a file URL, reducing memory pressure.

func downloadPDF() async throws -> URL {
    let request = URLRequest(
        url: URL(string: "https://api.example.com/report.pdf")!
    )
    let (temporaryURL, response) = try await URLSession.shared.download(for: request)
    try validate(response)

    let destination = FileManager.default.temporaryDirectory
        .appendingPathComponent("report.pdf")
    try? FileManager.default.removeItem(at: destination)
    try FileManager.default.moveItem(at: temporaryURL, to: destination)
    return destination
}

The temporary file must be moved or copied before the system removes it. For user-visible long-running transfers, use a background URLSessionConfiguration and delegate-based progress and completion handling. Background sessions are designed for transfers that may continue when the application is suspended; they involve additional delegate and lifecycle configuration.

Cancellation, Timeouts, and Retries

An asynchronous task can be cancelled when a view disappears or a newer search supersedes an older search. Code should avoid publishing results after cancellation:

func search(query: String) async throws -> [Book] {
    try Task.checkCancellation()
    // Construct and execute request here.
    let result: [Book] = try await client.get("search", as: [Book].self)
    try Task.checkCancellation()
    return result
}

Timeouts are not a substitute for good error handling. A timeout expresses how long the client is willing to wait; it does not guarantee that the server stopped processing the request. Automatic retries are appropriate only when the operation is safe to repeat or has idempotent semantics. Retrying a GET is usually less risky than blindly retrying a payment POST. A production retry policy should limit attempts, use increasing delays, and avoid retrying authentication or validation failures.

Security, Privacy, and Governance

Use HTTPS for confidential or authenticated traffic. iOS App Transport Security (ATS) generally requires secure connections and should not be weakened globally to accommodate an improperly configured server. If an exception is genuinely necessary, it should be narrowly scoped, documented, reviewed, and removed when possible.

Additional practices include:

  • validate server certificates through the system trust model rather than accepting all certificates;
  • store tokens in Keychain, not in UserDefaults or source code;
  • use short-lived access tokens and secure refresh mechanisms;
  • minimize transmitted personal data;
  • redact tokens and personal information from logs;
  • verify authorization on the server, because client-side checks are not security boundaries;
  • define retention, deletion, and consent practices for collected data.

Networking also has accessibility and reliability implications. A loading indicator should not prevent users from understanding an error, and a retry action should be explicit where repeated requests could have side effects.

Illustrative Case: A Campus Library Application

Background. A campus library application displays books, supports search, and lets students upload a profile photograph.

Problem. The first implementation concatenated search strings into URLs, decoded JSON directly in a view, ignored status codes, and updated UI properties from arbitrary completion handlers. It also loaded photographs entirely into memory.

Analysis. Search terms containing spaces or symbols produced invalid requests. A server-side 500 response was displayed as an empty list, hiding the real problem. Concurrent searches could finish out of order, causing an older result to replace a newer one. Large images caused memory pressure.

Approach. The team introduced an APIClient using URLComponents, URLRequest, and async/await. A @MainActor view model owned loading, success, and error states. Responses were validated before decoding. Search tasks were cancelled when the query changed, and the server endpoint was changed to accept a multipart upload for photographs. A background session was reserved for large document downloads.

Outcome. Requests became correctly encoded, server failures became visible and actionable, and UI state was updated predictably. Memory use decreased for large downloads, while tests used a custom session configuration and fixture responses.

Learning points. Correct networking is not merely the ability to receive bytes. It requires contract validation, lifecycle management, security controls, cancellation, testability, and clear communication of network state to users.

Best Practices Checklist

  1. Build query strings with URLComponents.
  2. Set the HTTP method and appropriate Accept and Content-Type headers.
  3. Validate the response type and status code before decoding.
  4. Model JSON with Decodable and configure date or key strategies explicitly.
  5. Keep networking out of view bodies and centralize it in a client or service.
  6. Isolate UI state with @MainActor.
  7. Support cancellation and distinguish it from failure.
  8. Use downloads for large files and background sessions for long-running transfers.
  9. Store secrets in Keychain and use HTTPS.
  10. Inject sessions so network behavior can be tested deterministically.

Challenges and Limitations

URLSession does not define an API’s business semantics. The developer must understand authentication, pagination, rate limits, versioning, error formats, and idempotency from the service contract. Connectivity may disappear after a request begins, clocks may be inaccurate, and server responses may change. Caching can improve responsiveness but can also display stale data. Offline support requires an explicit local persistence and synchronization strategy rather than simply retrying every request. Finally, asynchronous code reduces thread-management complexity but does not remove the need for data isolation, cancellation design, observability, and security review.

Chapter Summary

URLSession is the central iOS abstraction for HTTP and HTTPS communication. URLRequest expresses the endpoint, method, headers, body, and timeout. async/await makes asynchronous transfers readable without blocking the calling thread. Reliable clients validate HTTP status codes, decode documented response models with Codable, and distinguish transport, server, and decoding failures. Small payloads can use data or upload, while file transfers are better handled by download and, when appropriate, background sessions. UI state should be isolated on the main actor, and tasks should support cancellation. Secure transport, Keychain storage, privacy-aware logging, testable architecture, and explicit retry policies are essential parts of professional networking code.

Key Terms

URL: A structured identifier for a network resource.

URLRequest: A value describing an HTTP request.

URLSession: The framework object that manages network data, upload, and download tasks.

HTTP method: A verb such as GET, POST, PUT, PATCH, or DELETE that communicates request intent.

Header: Metadata transmitted with a request or response.

HTTP status code: A numeric result indicating the broad outcome of an HTTP request.

Payload: The body data transmitted in a request or response.

URLComponents: A type for safely constructing URLs and query items.

Codable: Swift’s protocol composition for encoding and decoding values.

JSONDecoder: An object that converts JSON data into Decodable values.

async/await: Swift syntax for structured asynchronous programming.

Main actor: Swift’s isolation context for main-thread UI-related state.

Cancellation: A cooperative request to stop asynchronous work.

Timeout: The maximum configured waiting interval for an operation.

Upload: Transfer of data from the device to a server.

Download: Transfer of data from a server to the device.

ATS: App Transport Security rules that encourage secure network connections.

Idempotent operation: An operation that has the same intended result when repeated.

Dependency injection: Supplying collaborators, such as a URLSession, from outside a type.

Review Questions

Short Answer Questions

  1. What responsibilities are represented by a URLRequest?
  2. Why should an HTTP status code be checked before decoding a response?
  3. What is the purpose of the Accept header?
  4. Why is a downloaded temporary file moved to a destination URL?
  5. Why should UI state commonly be isolated with @MainActor?

Long Answer Questions

  1. Explain the complete lifecycle of a JSON GET request using URLRequest, URLSession, validation, and JSONDecoder.
  2. Compare data(for:), upload(for:from:), and download(for:), including suitable use cases for each.
  3. Discuss how cancellation, timeouts, and retries affect the reliability of an iOS network client.
  4. Explain how URLComponents, HTTP headers, Codable models, and dependency injection contribute to maintainable networking code.
  5. Discuss the security and privacy risks of mobile networking and describe appropriate controls.

Analytical / Application Questions

  1. A search screen sometimes displays results for an earlier query after the user has typed a newer query. Diagnose the likely concurrency problem and propose a solution using task cancellation or request identification.
  2. An API returns HTTP 200 with a JSON error object instead of the documented book array. Design a validation and decoding strategy that prevents the UI from interpreting the error as an empty book list.
  3. A user uploads a 200 MB video over an unreliable connection. Recommend a transfer approach, session configuration, error and retry policy, and UI feedback strategy. Justify each choice.

Multiple Choice Questions with Answers

Question 1

Which type is used to describe an HTTP method, headers, body, and URL together?

A. URLRequest

B. JSONDecoder

C. URLComponents

D. URLResponse

Correct Answer: A. URLRequest

Question 2

Which method is most appropriate for receiving a modest JSON response in an async function?

A. download(for:)

B. data(for:)

C. upload(for:from:)

D. invalidateAndCancel()

Correct Answer: B. data(for:)

Question 3

What should a client normally do before decoding a response body as a successful result?

A. Ignore the status code

B. Validate the HTTP response and status code

C. Convert the body to a URL

D. Retry every response twice

Correct Answer: B. Validate the HTTP response and status code

Question 4

Which Swift type is used to convert JSON data into a Decodable model?

A. JSONEncoder

B. URLSessionConfiguration

C. JSONDecoder

D. FileManager

Correct Answer: C. JSONDecoder

Question 5

Why is URLComponents preferred over manually concatenating query strings?

A. It automatically authenticates the user

B. It percent-encodes query components correctly

C. It downloads files in the background

D. It guarantees a successful server response

Correct Answer: B. It percent-encodes query components correctly

Question 6

Where should SwiftUI view-model state that directly drives the interface generally be isolated?

A. A detached task only

B. The global actor for networking

C. The main actor

D. A URLProtocol subclass

Correct Answer: C. The main actor

Question 7

Which operation is generally safer to retry automatically than a payment-creating POST?

A. A GET request

B. A password reset request

C. A non-idempotent purchase request

D. Every request equally

Correct Answer: A. A GET request

Question 8

What is a principal advantage of download(for:) over data(for:) for a large file?

A. It guarantees the file is valid

B. It writes the result to a temporary file instead of holding all data in memory

C. It removes the need for HTTPS

D. It automatically stores credentials in Keychain

Correct Answer: B. It writes the result to a temporary file instead of holding all data in memory

Question 9

Where should an authentication token normally be stored on iOS?

A. A hard-coded string in the app

B. The URL query string

C. UserDefaults without protection

D. Keychain

Correct Answer: D. Keychain

Question 10

Why is injecting URLSession into an API client useful?

A. It prevents all network errors

B. It allows deterministic tests with a controlled session or URLProtocol

C. It makes every request synchronous

D. It bypasses HTTP status codes

Correct Answer: B. It allows deterministic tests with a controlled session or URLProtocol

APIs & Data Management
Chapter 4 of 14