Skip to content

Swift Networking — URLSession, Codable, and REST API Integration

DodaTech Updated 2026-06-28 11 min read

In this tutorial, you will learn about Swift Networking. We cover key concepts, practical examples, and best practices to help you master this topic.

Swift networking uses URLSession to perform HTTP requests combined with the Codable protocol to automatically serialize and deserialize JSON data, providing a type-safe, modern approach to API integration.

What You'll Learn

  • URLSession basics and configuration
  • Making GET and POST requests with async/await
  • The Codable protocol for JSON serialization
  • Error handling and response validation
  • Building a reusable networking layer
  • Security considerations (HTTPS, certificate pinning)

Why It Matters

Nearly every modern app communicates with a server. From fetching weather data to submitting user profiles, networking is fundamental. Swift's URLSession combined with Codable provides a first-party solution that is secure, performant, and deeply integrated with the language's type system.

Real-World Use

A news app fetches articles from a REST API. The response JSON is automatically decoded into an array of Article structs via Codable. Each article is then displayed in a UITableView. When the user taps an article, a detail screen is pushed. All networking is handled by a generic APIClient that works with any Codable type.

Learning Path

flowchart LR
  A[Navigation
Lesson 19] --> B[Networking
You are here] B --> C[Data Persistence
Lesson 21] B --> D[Concurrency
Lesson 22] style B fill:#f90,color:#fff

URLSession Basics

URLSession is the foundation of networking in Swift. It manages HTTP requests, authentication, Caching, and cookie storage.

import Foundation

let session = URLSession.shared

func fetchData() {
    let url = URL(string: "https://api.example.com/data")!
    let task = session.dataTask(with: url) { data, response, error in
        if let error = error {
            print("Network error: \(error.localizedDescription)")
            return
        }
        if let data = data {
            let result = String(data: data, encoding: .utf8) ?? ""
            print("Received: \(result.prefix(100))...")
        }
    }
    task.resume()
}

fetchData()

Output: Received: {"status":"ok","items":[...]...

The shared Singleton session is suitable for most basic requests. The dataTask runs asynchronously and calls its completion handler on a background queue. You must call resume() to start the task — tasks start in a suspended state.

Custom Session Configuration

For more control, create custom session configurations.

let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
configuration.waitsForConnectivity = true
configuration.allowsCellularAccess = true
configuration.httpAdditionalHeaders = [
    "Accept": "application/json",
    "User-Agent": "MyApp/1.0"
]

let customSession = URLSession(configuration: configuration)
print("Custom session created with 30s timeout")

Making GET Requests with async/await

Swift's concurrency model makes networking code much cleaner with async/await.

import Foundation

struct Todo: Decodable, Identifiable {
    let id: Int
    let title: String
    let completed: Bool
}

func fetchTodos() async throws -> [Todo] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse else {
        throw URLError(.badServerResponse)
    }

    guard (200...299).contains(httpResponse.statusCode) else {
        throw NetworkError.httpError(httpResponse.statusCode)
    }

    let decoder = JSONDecoder()
    return try decoder.decode([Todo].self, from: data)
}

enum NetworkError: Error, CustomStringConvertible {
    case httpError(Int)
    case decodingError(String)

    var description: String {
        switch self {
        case .httpError(let code):
            return "HTTP error: \(code)"
        case .decodingError(let message):
            return "Decoding error: \(message)"
        }
    }
}

// Usage:
// Task {
//     do {
//         let todos = try await fetchTodos()
//         for todo in todos.prefix(3) {
//             print("\(todo.id): \(todo.title) [\(todo.completed ? "x" : " ")]")
//         }
//     } catch {
//         print("Failed: \(error)")
//     }
// }

The async version of data(from:) returns both data and response. Check the HTTP status code before decoding. The method throws on network errors automatically.

Making POST Requests

POST requests send data to the server, typically JSON-encoded.

import Foundation

struct NewTodo: Encodable {
    let title: String
    let completed: Bool
    let userId: Int
}

struct CreatedTodo: Decodable {
    let id: Int
    let title: String
    let completed: Bool
    let userId: Int
}

func createTodo() async throws -> CreatedTodo {
    let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let newTodo = NewTodo(title: "Learn Swift Networking", completed: false, userId: 1)
    request.httpBody = try JSONEncoder().encode(newTodo)

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

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 201 else {
        throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
    }

    return try JSONDecoder().decode(CreatedTodo.self, from: data)
}

// Usage:
// Task {
//     do {
//         let created = try await createTodo()
//         print("Created todo #\(created.id): \(created.title)")
//     } catch {
//         print("POST failed: \(error)")
//     }
// }

For POST requests, construct a URLRequest manually, set the method and headers, and encode the body. Use data(for:) instead of data(from:) for request-based calls.

The Codable Protocol

Codable combines Encodable and Decodable into a single protocol for type-safe JSON handling.

import Foundation

struct User: Codable {
    let id: Int
    let name: String
    let username: String
    let email: String
    let address: Address
    let phone: String
    let website: String
    let company: Company
}

struct Address: Codable {
    let street: String
    let suite: String
    let city: String
    let zipcode: String
    let geo: GeoLocation
}

struct GeoLocation: Codable {
    let lat: String
    let lng: String
}

struct Company: Codable {
    let name: String
    let catchPhrase: String
    let bs: String
}

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://jsonplaceholder.typicode.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

func encodeUser(_ user: User) throws -> String {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
    let data = try encoder.encode(user)
    return String(data: data, encoding: .utf8) ?? ""
}

// Usage:
// Task {
//     do {
//         let user = try await fetchUser(id: 1)
//         print("User: \(user.name) (\(user.email))")
//         print("City: \(user.address.city)")
//         let json = try encodeUser(user)
//         print(json)
//     } catch {
//         print("Error: \(error)")
//     }
// }

Nested JSON objects map to nested Codable structs. The decoder automatically matches JSON keys to struct property names. Use CodingKeys for custom mappings when JSON keys differ from Swift conventions.

Custom Coding Keys

struct Post: Codable {
    let id: Int
    let title: String
    let body: String
    let userID: Int

    enum CodingKeys: String, CodingKey {
        case id, title, body
        case userID = "userId"
    }
}

func decodePost(from json: String) {
    let data = Data(json.utf8)
    let post = try? JSONDecoder().decode(Post.self, from: data)
    if let post = post {
        print("Post #\(post.id) by user \(post.userID): \(post.title)")
    }
}

let json = """
{
    "id": 1,
    "title": "Hello",
    "body": "World",
    "userId": 42
}
"""
decodePost(from: json)

Output: Post #1 by user 42: Hello

The CodingKeys enum maps userID to the JSON key "userId". Swift automatically generates coding keys that match property names, so you only need to override mismatches.

Building a Reusable Networking Layer

A generic API client abstracts networking details so view controllers never deal with URLSession directly.

import Foundation

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
    case patch = "PATCH"
}

protocol APIEndpoint {
    var baseURL: String { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var headers: [String: String] { get }
    var body: Data? { get }
}

extension APIEndpoint {
    var baseURL: String { return "https://jsonplaceholder.typicode.com" }
    var headers: [String: String] { return [:] }
    var body: Data? { return nil }

    func asURLRequest() throws -> URLRequest {
        guard let url = URL(string: baseURL + path) else {
            throw NetworkError.invalidURL
        }
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.allHTTPHeaderFields = headers
        request.httpBody = body
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        return request
    }
}

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError(Error)
    case httpError(Int)
    case networkError(Error)
}

class APIClient {
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetch<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
        let request = try endpoint.asURLRequest()
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.httpError(0)
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.httpError(httpResponse.statusCode)
        }

        do {
            let decoder = JSONDecoder()
            return try decoder.decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingError(error)
        }
    }

    func fetchRaw(_ endpoint: APIEndpoint) async throws -> Data {
        let request = try endpoint.asURLRequest()
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
        }
        return data
    }
}

struct GetTodosEndpoint: APIEndpoint {
    var path: String { return "/todos" }
    var method: HTTPMethod { return .get }
}

struct GetUserEndpoint: APIEndpoint {
    let userID: Int
    var path: String { return "/users/\(userID)" }
    var method: HTTPMethod { return .get }
}

// Usage:
// let client = APIClient()
// Task {
//     let todos: [Todo] = try await client.fetch(GetTodosEndpoint())
//     print("Fetched \(todos.count) todos")
// }

This layer separates endpoint definitions from execution. Adding a new API endpoint requires only a new struct conforming to APIEndpoint.

Downloading Images

For binary data like images, use data(from:) and convert to UIImage.

import UIKit

func downloadImage(from urlString: String) async throws -> UIImage {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL
    }
    let (data, _) = try await URLSession.shared.data(from: url)
    guard let image = UIImage(data: data) else {
        throw NetworkError.decodingError("Invalid image data" as! Error)
    }
    return image
}

// Usage:
// Task {
//     do {
//         let image = try await downloadImage(from: "https://example.com/image.png")
//         print("Image downloaded: \(image.size.width)x\(image.size.height)")
//     } catch {
//         print("Image download failed: \(error)")
//     }
// }

Uploading Data

Upload tasks send data to a server, typically with multipart form encoding for files.

func uploadProfileImage(data: Data, filename: String) async throws {
    let url = URL(string: "https://api.example.com/upload")!

    let boundary = UUID().uuidString
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("multipart/form-data; boundary=\(boundary)",
                     forHTTPHeaderField: "Content-Type")

    var body = Data()
    body.append("--\(boundary)\r\n".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
    body.append("Content-Type: image/png\r\n\r\n".data(using: .utf8)!)
    body.append(data)
    body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
    request.httpBody = body

    let (_, response) = try await URLSession.shared.data(for: request)
    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
    }
    print("Upload successful")
}

Security Best Practices

Always use HTTPS, validate server certificates, and avoid hardcoding API keys.

import Security

class SecureSessionDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge) async
        -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        guard challenge.protectionSpace.authenticationMethod ==
              NSURLAuthenticationMethodServerTrust else {
            return (.performDefaultHandling, nil)
        }

        // In production, validate the server certificate here
        // against a pinned certificate or public key
        if let serverTrust = challenge.protectionSpace.serverTrust {
            let credential = URLCredential(trust: serverTrust)
            return (.useCredential, credential)
        }
        return (.cancelAuthenticationChallenge, nil)
    }
}

let secureDelegate = SecureSessionDelegate()
let secureSession = URLSession(configuration: .default,
                                delegate: secureDelegate,
                                delegateQueue: nil)
print("Secure session configured with certificate validation")

Common Mistakes

  1. Forgetting to call resume(): Tasks start in a suspended state. Without task.resume(), the request never executes.

  2. Not checking HTTP status codes: A 404 or 500 response still returns data. Always validate the status code before decoding.

  3. Decoding on the main thread: Network completion handlers run on background threads. Dispatch UI updates to the main queue or use MainActor.

  4. Ignoring error types: Network errors, decoding errors, and HTTP errors all need distinct handling. Use a custom error enum to differentiate.

  5. Hardcoding API keys in source code: API keys in source code can be extracted from the binary. Use environment variables, configuration files, or a backend proxy.

Practice Questions

  1. Why must you call resume() on a URLSessionTask?
  2. How does the Codable protocol simplify JSON handling?
  3. What is the difference between data(from:) and data(for:) in URLSession?
  4. How would you handle a 401 Unauthorized response?
  5. Challenge: Build a generic API client that supports GET, POST, PUT, and DELETE methods. Include automatic retry logic for 5xx errors (up to 3 retries with exponential backoff).

Mini Project

Create a GitHubUserViewer app with:

  • A struct GitHubUser: Codable with login, id, avatar_url, name, public_repos
  • A function searchUsers(query: String) async throws -> [GitHubUser]
  • A function fetchUserDetails(username: String) async throws -> GitHubUser
  • An APIClient class with generic fetch<T: Decodable> method
  • Error handling for network errors, invalid JSON, and HTTP errors
  • Print the results of searching for "swift" and fetching the first user's details

FAQ

Should I use URLSession or a third-party library like Alamofire?

URLSession is sufficient for most apps and is the recommended Apple approach. Third-party libraries add dependencies and potential security risks. Consider Alamofire only if you need advanced features like request queuing or network reachability.

How do I handle authentication tokens?

Store tokens in the Keychain, not UserDefaults. Add them to the Authorization header: request.setValue('Bearer TOKEN', forHTTPHeaderField: 'Authorization').

What is the difference between dataTask and downloadTask?

dataTask returns data in memory, suitable for small responses. downloadTask writes to a temporary file, suitable for large files like images or videos.

How do I cancel a network request?

Store the task and call task.cancel(). For async/await, cancellation is cooperative — check Task.isCancelled or Task.checkCancellation() in your code.

What is ATS (App Transport Security)?

ATS enforces HTTPS connections and secure TLS versions. HTTP connections are blocked by default. Configure exceptions in Info.plist only when absolutely necessary.

What's Next

After mastering networking, learn how to persist data locally with Data Persistence using Core Data, SwiftData, or UserDefaults, and explore Concurrency for advanced async patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro