Persistence and networking · core
Networking with URLSession
Five lines fetch JSON and turn it into Swift values. Everything that makes an app rather than a demo happens around those five lines — status codes, decoding failures, retries and cancellation.
By the end you will be able to
- Fetch and decode JSON with async URLSession, and check the status code before decoding
- Design the error type a networking layer should throw
- Handle the failures that happen in production — status codes, bad payloads, cancellation, retries
URLSession.shared.data(from: url) returns without throwing. The server actually responded with 404 Not Found. What is in your hands?
The five lines that work when nothing goes wrong
struct Article: Codable {
let id: Int
let title: String
let publishedAt: Date
}
func fetchArticles() async throws -> [Article] {
let url = URL(string: "https://api.example.com/articles")!
let (data, response) = try await URLSession.shared.data(from: url)
// …status check goes here — next section…
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode([Article].self, from: data)
}
Three pieces of vocabulary sit inside that code.
Codable is the protocol that lets Swift turn a value into JSON and turn JSON back into a value. You met it in S2-06. Writing struct Article: Codable is usually the whole job, because the compiler writes the conversion for you.
To decode means to turn the bytes that arrived from the server into Swift values you can use. JSONDecoder does that job. The same trip in the other direction — Swift values turned into bytes to send — is encoding.
The two decoder settings deal with the two mismatches you meet most often between a server's JSON and a Swift type. keyDecodingStrategy = .convertFromSnakeCase matches the server's published_at to the property publishedAt. dateDecodingStrategy = .iso8601 turns a date sent as the text 2026-08-12T09:30:00Z into a Date.
The call itself hands back two values: the reply body as Data, and a URLResponse describing the reply. Those five lines appear in every tutorial. Everything that separates production code from a tutorial happens around them.
The status check, and the error type worth having
Design the error enum before you write the request. The list of errors your networking layer can throw is the promise it makes to the rest of the app, so it is worth writing down first. Each case below exists because it leads to different behaviour on screen:
enum APIError: Error {
case invalidURL
case transport(underlying: Error) // offline, timeout — retryable, show "check connection"
case serverError(status: Int) // 5xx — their fault, retry later
case clientError(status: Int) // 4xx — our fault, do NOT blind-retry
case decoding(underlying: Error) // payload surprise — log loudly, file a bug
}
func fetchArticles() async throws -> [Article] {
// …
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(underlying: URLError(.badServerResponse))
}
switch http.statusCode {
case 200...299: break
case 500...599: throw APIError.serverError(status: http.statusCode)
default: throw APIError.clientError(status: http.statusCode)
}
// …decode…
}
Two families of status code carry most of the meaning. A code in the 4xx range means the server understood the request and refused it, because something about the request was wrong: 401 means the caller is not signed in, 404 means there is nothing at that address. A code in the 5xx range means the request was fine and the server itself failed: 500 is an unhandled error on their side, and 503 means the server is temporarily unavailable.
Keeping the two families apart is not tidiness. They need opposite behaviour.
- 503. The server is busy for the moment. Wait, then send the same request again. Sending a request again after a failure is called a retry.
- 401. The session has expired. The same request will fail the same way forever. The app has to get the user signed in again.
- 404. The article was deleted. A retry adds load to the server and can never succeed.
Here is what a single merged networkError case costs. Follow the sequence:
- A user's login token expires while the phone is in their pocket.
- They open the app and the feed refreshes. The server answers 401.
- The networking layer throws its one
networkErrorcase. The status code is not carried along with it. - The screen shows "Something went wrong" and a Retry button, because that is all an app can offer for an error that says nothing.
- The user taps Retry. The token is still expired, so the server answers 401 again. Nothing on screen changes.
The user is now in a loop the app cannot get out of. The one action that would end it — send them to the sign-in screen — was known in step 2 and thrown away in step 3.
One simplification in the code above is worth naming. Anything that is neither 2xx nor 5xx is treated as a client error, which sweeps in the rarely seen 1xx and 3xx codes too. In practice a 3xx redirect almost never reaches your code, because URLSession follows redirects itself before returning.
Decoding failures are information
A DecodingError from JSONDecoder is more helpful than most errors. It names the exact property that failed and where in the JSON that property sat. Catch it and replace it with "Something went wrong" and you throw all of that away, turning a thirty-second fix into an afternoon of guessing:
do {
return try decoder.decode([Article].self, from: data)
} catch let error as DecodingError {
// .keyNotFound("title", path: [2]) — the third article is missing a title
logger.error("decode failed: \(error)")
throw APIError.decoding(underlying: error)
}
Backends change without asking you first. Two habits keep your decoding standing when they do.
Make a property optional when the server may leave it out. If the backend sometimes omits the summary, write let summary: String?. Do not write let summary: String and cover the gap with summary ?? "".
That default looks like the safe choice and is the more dangerous one. Follow what it does:
- The server omits
summaryfor some articles. summary ?? ""turns each missing summary into an empty string. The decode succeeds, so nothing throws and nothing is logged.- Those cards render with a blank gap where the summary should be. It reads as a spacing bug, not as missing data.
- Nobody reports it, because nothing looks broken enough to report. The screen stays quietly wrong for months.
String? stops that at step 2. The optional forces the screen to decide what a missing summary looks like — a shorter card, or a line of placeholder text that somebody chose on purpose. A failure you can see costs less than a screen that is wrong and silent.
Do not decode a server-owned enum straight into a Swift enum. enum Plan: String, Codable { case free, pro } works until the backend adds a third value. Then one unfamiliar string fails the decode of the whole payload, not just that one property. Decode the raw string instead, and map it to your enum yourself with a fallback case for values you have not seen. The quiz below follows that failure through a shipped app.
The example below runs the whole sequence — request, status check, decode — against three stand-in servers.
Those stand-ins have a name. A test double is any small type you write yourself to stand in for a real dependency while a test runs. The kind used here is a fake: a working stand-in whose behaviour you decide. HealthyServer answers 200 with a body that decodes. BrokenServer answers 503. LyingServer answers 200, which tells the app that all is well, and then hands back a body that cannot be decoded. Each one is three lines long, and each reproduces a failure that is awkward to arrange with a real server.
Transport is the seam they plug into, so APIClient never learns which of the three it is holding.
Look at what the status check did for BrokenServer. Without it, the maintenance HTML would have travelled on to the decoder, and the line printed would have been broken: payload surprise — bad line: <html>maintenance</html>. That is a decoding error reported for what is really a server problem. You would spend the afternoon reading your Article struct, and the bug was never in it.
Retries — for the failures where retrying helps
Some failures pass on their own. A timeout or a 503 usually means the server was busy for a moment, so sending the same request again shortly afterwards often works. A failure that passes on its own like this is called transient.
Other failures never pass on their own. A 401 stays a 401 until the user signs in again. A 404 stays a 404 until somebody puts the content back. Retrying those is worse than useless: many servers count failed sign-in attempts and lock the account after a handful of them, so a loop that retries a 401 by itself can lock out a user who did nothing wrong.
When you do retry, leave more room between the attempts each time. Exponential backoff means the wait doubles after every failed attempt — 100 ms, then 200 ms, then 400 ms. The doubling matters because the usual cause of a 503 is a server that is already overloaded. Clients that retry immediately and repeatedly keep it overloaded. Clients that back off give it room to recover.
In the loop below, the server fails twice and then works. That is what a short outage looks like from the client's side.
Two things are missing from that loop before it is ready for production.
Jitter. Add a small random amount to each delay — plus or minus 20 percent is a common choice. That randomness is what the word jitter means here. Without it, every client that failed at the same moment also retries at the same moment. Picture a server that goes down for one second at 09:00:00. Fifty thousand phones fail together, all wait exactly 100 ms, and all retry together at 09:00:00.1. The wave of retries knocks the server over again, and the same thing happens at 200 ms, then 400 ms, then 800 ms. Jitter spreads those attempts out so the load arrives smoothly instead of in waves.
Two more things belong here, and D2-07 builds both. The first is that a 429 or a 503 often arrives with a Retry-After header saying exactly how long to wait — and the server's own number beats any curve you invent, so honour it when it is there and fall back to the doubling above when it is not. The second is larger, and it is the question this loop never asks: whether repeating the request is safe at all. Retrying GET /articles is harmless. Retrying an unacknowledged POST /payments can move the money twice, because a timeout tells you the reply was lost, never whether the request arrived. The rule that completes this section is "retry only what is idempotent or carries an idempotency key", and D2-07 is where it is made precise.
Cancellation. Look closely at the sleep in that loop: it is try await, not try?. The difference matters more than it looks. Task.sleep throws a CancellationError when the surrounding task is cancelled, and try lets that error travel out of the loop and end it. Writing try? instead would discard the error, so a user who navigated away mid-wait would leave the loop running — waiting, retrying and eventually delivering a result to a screen that no longer exists. Retries are the easiest place in an app to leak work, precisely because the loop spends most of its life asleep. If your retry loop lives somewhere that cannot throw, call try Task.checkCancellation() at the top of each attempt instead.
Where this meets SwiftUI
Nothing in this section is a new idea. The screen state is the enum with associated values from S2-03. The work runs in SwiftUI's .task modifier. The model owns the order in which things happen, and the view only reads the result:
@Observable
final class FeedModel {
enum State {
case loading
case loaded([Article])
case failed(String) // the user-facing message, chosen from APIError
}
private(set) var state: State = .loading
private let client: ArticleFetching
init(client: ArticleFetching) { self.client = client }
func load() async {
state = .loading
do {
state = .loaded(try await client.fetchArticles())
} catch APIError.transport {
state = .failed("You appear to be offline.")
} catch {
state = .failed("Couldn't load the feed.")
}
}
}
struct FeedView: View {
let model: FeedModel
var body: some View {
content
.task { await model.load() } // cancelled automatically on disappear
.refreshable { await model.load() }
}
// …switch over model.state…
}
ArticleFetching is a protocol that the feature declares for its own use, named in the feature's words. It is the same kind of seam as Transport above: a test can hand FeedModel a fake and check all three states without a server anywhere.
.task starts its work when the view appears and cancels that work when the view goes away. This is the cancellation from C1-01 doing real work. Navigate away while the request is in flight and the request is cancelled along with the view. There is no [weak self] to remember, and no result arrives for a screen that has already gone.
Your app decodes let plan: Plan where enum Plan: String, Codable { case free, pro }. Marketing launches an "enterprise" tier. What happens to existing app versions fetching an enterprise user's profile?
Design the networking layer for a notes app that has to keep working without a connection. Write down four things: the Transport protocol, the error enum, which failures get a retry and which do not, and the message each failure puts on screen. Then answer the hard question. A save fails because the phone is offline. What happens to the note, and what does the user see at that moment?
You can now:
- Fetch and decode with async URLSession, checking the status code before you decode
- Design an error enum whose cases lead to different behaviour by the app
- Protect models against a backend that changes — optionals for properties the server may omit, and enums that tolerate values you have not seen
- Retry the failures a retry can fix, back off between attempts, and let cancellation through
Next up: SwiftData — storing models on the device, so the app has something to show before the network answers. Later in this unit, D2-07 returns to the request side: the methods, the status codes below the families, and the rule that decides whether the retry loop above is safe to point at a given request.