Networking, persistence and Codable

URLSession with async and await, JSONDecoder and CodingKeys, mapping errors, caching, UserDefaults, file storage and SwiftData essentials.

Requests with async and await

struct Article: Decodable, Identifiable {
    let id: Int
    let title: String
    let body: String
    let publishedAt: Date

    enum CodingKeys: String, CodingKey {
        case id, title, body
        case publishedAt = "published_at"
    }
}

enum APIError: Error, LocalizedError {
    case status(Int)
    case transport(Error)
    case decoding(Error)

    var errorDescription: String? {
        switch self {
        case .status(let code): return "Server returned \(code)."
        case .transport: return "Could not reach the server."
        case .decoding: return "Unexpected response format."
        }
    }
}

struct API {
    static let shared = API()
    private let session: URLSession = {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 15
        config.waitsForConnectivity = true
        config.requestCachePolicy = .returnCacheDataElseLoad
        return URLSession(configuration: config)
    }()

    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.dateDecodingStrategy = .iso8601
        return d
    }()

    func article(_ id: Int) async throws -> Article {
        var request = URLRequest(url: URL(string: "https://api.example.com/articles/\(id)")!)
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else { throw APIError.status(0) }
            guard (200..<300).contains(http.statusCode) else { throw APIError.status(http.statusCode) }
            return try decoder.decode(Article.self, from: data)
        } catch let error as DecodingError {
            throw APIError.decoding(error)
        } catch let error as APIError {
            throw error
        } catch {
            throw APIError.transport(error)
        }
    }
}
  • URLSessionConfiguration is where timeouts, caching and connectivity waiting live — set them once instead of per request.
  • DecodingError names the exact key path that failed, so log it rather than swallowing it into a generic message.
  • Use one shared decoder, and set dateDecodingStrategy to match the server exactly: ISO 8601 with and without fractional seconds are different keys.
  • A cancelled task throws URLError.cancelled; treat it as normal and do not show an error.

UserDefaults, files and SwiftData

// UserDefaults: small, non-sensitive preferences only
enum Preferences {
    private static let key = "onboarding_complete"
    static var onboardingComplete: Bool {
        get { UserDefaults.standard.bool(forKey: key) }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

// files: anything larger, in the documents directory
struct FileStore {
    let directory: URL

    static func make() throws -> FileStore {
        let base = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask,
                                               appropriateFor: nil, create: true)
        return FileStore(directory: base.appendingPathComponent("cache", isDirectory: true))
    }

    func write(_ data: Data, name: String) throws {
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        try data.write(to: directory.appendingPathComponent(name), options: [.atomic, .completeFileProtection])
    }
}

// SwiftData: a model graph with queries
import SwiftData

@Model
final class Task {
    var title: String
    var done: Bool
    var due: Date?

    init(title: String, done: Bool = false, due: Date? = nil) {
        self.title = title
        self.done = done
        self.due = due
    }
}

@Query(filter: #Predicate<Task> { !$0.done }, sort: \.title) private var open: [Task]
StoreFitsAvoid
UserDefaultsFlags, small preferencesAnything secret or large
FilesDocuments, images, exportsStoring an index you must keep in sync
KeychainTokens, passwordsBulk data
SwiftDataRelational model graphsA schema that changes every release without migrations
Core DataMature apps with heavy customisationNew projects with no legacy constraint

Caching and background refresh

actor ArticleCache {
    private var entries: [Int: (article: Article, savedAt: Date)] = [:]

    func article(_ id: Int, maxAge: TimeInterval = 300) -> Article? {
        guard let entry = entries[id],
              Date.now.timeIntervalSince(entry.savedAt) < maxAge else { return nil }
        return entry.article
    }

    func store(_ article: Article) {
        entries[article.id] = (article, .now)
    }
}

func article(_ id: Int, cache: ArticleCache) async throws -> Article {
    if let cached = await cache.article(id) { return cached }
    let fresh = try await API.shared.article(id)
    await cache.store(fresh)
    return fresh
}
⚠️
A background refresh has a hard time budget and no guarantee of being scheduled. Do the minimum useful work, complete deterministically, and never assume it ran — the next foreground launch must still fetch what it needs.

FAQ

When should I move from Core Data to SwiftData?
For a new app, start with SwiftData unless you need features it lacks, such as complex fetch request customisation or existing tooling. Migrating an app with a non-trivial Core Data model is a project, not a swap.
How do I handle a server that changes its date format?
Write a custom dateDecodingStrategy closure with a list of formats to try, and log when the fallback is used. Relying on one exact format means a server deploy breaks every client at once.

SwiftUI: views, state and navigation Collections, strings and the standard library

Last refreshed 2026-09-18.