> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# HTTP requests in Swift: a practical URLSession guide
- URL: https://roundproxies.com/blog/requests-swift/
- Published: 2025-10-06T22:17:15.000Z
- Updated: 2026-09-24T13:28:29.000Z
- Description: HTTP requests in Swift with URLSession: GET, POST, JSON, errors, retries, and proxies. Code included.
- Author: Marius Bernard
- Tags: Knowledgebase, #dated-68e43e734fa498a2c6ee1f98

Sending HTTP requests in Swift takes one line of URLSession. Getting them right takes a few more.

URLSession will hand you a 500 error page as if it were your JSON, and its default timeout doesn't mean what most people assume.

This guide covers the parts that break in real apps: status codes, decoding failures, query encoding, timeouts, retries on 429, and routing traffic through a proxy.

Every example uses async/await and plain Foundation. Nothing to install.

## How do you make HTTP requests in Swift?

HTTP requests in Swift go through URLSession, Apple's built-in networking API. Build a URL, or a URLRequest when you need a method, headers, or a body. Call `try await URLSession.shared.data(for:)`, check the status code, then decode the JSON with `Codable`. GET, POST, headers, and auth need no third-party library.

Five types do nearly all the work:

| Type                | Job                                                               |
| ------------------- | ----------------------------------------------------------------- |
| URL / URLComponents | The address, plus safely encoded query parameters                 |
| URLRequest          | Method, headers, body, per-request timeout                        |
| URLSession          | Sends the request, pools connections, handles cookies and caching |
| HTTPURLResponse     | Status code and response headers                                  |
| Codable             | Turns JSON into Swift structs and back                            |

## Prerequisites

- Xcode 15 or later (Swift 5.9+)
- iOS 15 / macOS 12 for the async URLSession methods. Step 9 uses `Task.sleep(for:)`, which needs iOS 16\. The proxy section needs iOS 17 / macOS 14.
- Two free test APIs with no key: `httpbin.org` and `jsonplaceholder.typicode.com`

The snippets use top-level `await`, which works in a Swift script or a package's `main.swift`. Inside an app, wrap the calls in `Task { }` or SwiftUI's `.task { }`.

## Step 1: Send a GET request and check the status code

The minimal request is one `await`. The part people skip is reading the status code, so it's in the first example.

```swift
import Foundation

let url = URL(string: "https://httpbin.org/get")!

let (data, response) = try await URLSession.shared.data(from: url)

// A 404 or 500 does NOT throw. You have to check it yourself.
guard let http = response as? HTTPURLResponse else {
    throw URLError(.badServerResponse)
}

print("Status:", http.statusCode)
print(String(decoding: data, as: UTF8.self))

```

URLSession only throws when no HTTP response arrives at all: no connection, DNS failure, TLS error, timeout.

A 404 is a successful network call that happens to contain a 404\. Code that skips the `statusCode` check will try to decode an HTML error page and fail somewhere confusing.

## Step 2: Decode the JSON response with Codable

Define a struct that mirrors the JSON, then hand the bytes to `JSONDecoder`. JSONPlaceholder's `/todos/1` returns four fields.

```swift
struct Todo: Decodable {
    let userId: Int
    let id: Int
    let title: String
    let completed: Bool
}

let url = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
let (data, response) = try await URLSession.shared.data(from: url)

guard let http = response as? HTTPURLResponse,
      (200..<300).contains(http.statusCode) else {
    throw URLError(.badServerResponse)
}

let todo = try JSONDecoder().decode(Todo.self, from: data)
print(todo.title)   // "delectus aut autem"

```

Every property must exist in the JSON with the right type, or decoding throws. Make a property optional (`let dueDate: String?`) when the API sometimes leaves it out.

Real APIs rarely match Swift naming. Two decoder settings handle most of the mismatch:

```swift
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase   // "created_at" -> createdAt

decoder.dateDecodingStrategy = .custom { dateDecoder in
    let raw = try dateDecoder.singleValueContainer().decode(String.self)
    // The built-in .iso8601 rejects fractional seconds like "2026-09-24T08:15:30.123Z"
    return try Date(raw, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
}

```

The fractional-seconds problem catches a lot of people. `.iso8601` looks correct, passes your tests against mock data, then fails the first time a backend sends milliseconds.

If your API mixes both formats, try the fractional parser first and fall back to the plain one inside the same closure.

## Step 3: Add query parameters with URLComponents

Never build query strings with string interpolation. `URLComponents` percent-encodes values for you, with one exception worth knowing.

```swift
var components = URLComponents(string: "https://httpbin.org/get")!
components.queryItems = [
    URLQueryItem(name: "q", value: "c++ tutorials"),
    URLQueryItem(name: "page", value: "2")
]

// URLComponents leaves "+" alone, and most servers decode "+" as a space
components.percentEncodedQuery = components.percentEncodedQuery?
    .replacingOccurrences(of: "+", with: "%2B")

guard let url = components.url else { throw URLError(.badURL) }
print(url)   // https://httpbin.org/get?q=c%2B%2B%20tutorials&page=2

let (data, _) = try await URLSession.shared.data(from: url)

```

Without the `%2B` line, the server receives "c tutorials". Apple's behavior follows RFC 3986, where `+` is legal in a query, but most web frameworks still treat it as a space.

On iOS 16+, `url.appending(queryItems:)` is a shorter way to add items to an existing URL. It has the same `+` behavior.

## Step 4: Send a POST request with a JSON body

POST needs a `URLRequest`, so you can set the method, the `Content-Type` header, and the body. Encode the body from a struct rather than a dictionary.

```swift
struct NewPost: Encodable {
    let title: String
    let body: String
    let userId: Int
}

var request = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(
    NewPost(title: "Hello", body: "Sent from URLSession", userId: 1)
)

let (data, response) = try await URLSession.shared.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? -1)   // 201
print(String(decoding: data, as: UTF8.self))

```

Note `data(for:)` instead of `data(from:)`. The `for` version takes a configured `URLRequest`; the `from` version takes a bare URL and always sends a GET.

PUT, PATCH, and DELETE work the same way. Change `httpMethod` and drop the body for DELETE.

Some older APIs and most login forms want form encoding instead of JSON. `URLComponents` builds that body too:

```swift
var form = URLComponents()
form.queryItems = [
    URLQueryItem(name: "username", value: "marius"),
    URLQueryItem(name: "remember", value: "1")
]

request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)

```

The `+` caveat from Step 3 applies here as well, so run the same `%2B` replacement if values can contain a plus sign.

## Step 5: Set headers and authentication

Headers that belong on every request go on the session. Headers that change per request, like a bearer token, go on the `URLRequest`.

```swift
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = [
    "User-Agent": "PriceWatch/2.1 (iOS)",
    "Accept": "application/json",
    "Accept-Language": "en-US"
]
let session = URLSession(configuration: config)

let token = "abc123"
var request = URLRequest(url: URL(string: "https://httpbin.org/bearer")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

let (_, response) = try await session.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? -1)   // 200 here, 401 without the header

```

A header set on the `URLRequest` wins over the same header from `httpAdditionalHeaders`.

Without a custom `User-Agent`, URLSession sends something like `YourApp/1 CFNetwork/... Darwin/...`. Your own backend won't care. Some public sites block that pattern on sight.

One honest caveat: Apple's documentation lists `Authorization` among the headers the URL loading system reserves. Setting a bearer token per request works fine in practice and is what nearly every app does.

For Basic or Digest challenges, where the server asks for credentials, answer with a `URLCredential` in a session delegate instead.

## Step 6: Handle errors by where they failed

A request can fail in three different places, and each one needs a different response in your UI. One error type makes that explicit.

```swift
enum APIError: Error {
    case transport(URLError)                                 // offline, timeout, DNS, TLS
    case status(Int, body: Data, retryAfter: TimeInterval?)  // server replied, but not 2xx
    case decoding(DecodingError)                             // 2xx, but JSON didn't fit the model
}

```

Now one generic function sends a request, sorts the failure into the right bucket, and decodes the result. The rest of this guide builds on it.

```swift
func send<T: Decodable>(_ request: URLRequest, as type: T.Type,
                        session: URLSession = .shared,
                        decoder: JSONDecoder = JSONDecoder()) async throws -> T {
    let result: (Data, URLResponse)
    do { result = try await session.data(for: request) }
    catch let error as URLError { throw APIError.transport(error) }
    let (data, response) = result
    guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }

    guard (200..<300).contains(http.statusCode) else {
        let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap { Double($0) }
        throw APIError.status(http.statusCode, body: data, retryAfter: retryAfter)
    }
    do { return try decoder.decode(T.self, from: data) }
    catch let error as DecodingError { throw APIError.decoding(error) }
}

```

Keeping the response `body` on status errors matters more than it looks.

Most APIs explain what went wrong in the body of a 400 or 422, and a bare status code throws that away.

At the call site, each failure gets its own branch:

```swift
let request = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/todos/1")!)

do {
    let todo = try await send(request, as: Todo.self)
    print(todo.title)
} catch APIError.transport(let error) where error.code == .notConnectedToInternet {
    print("Offline. Show a retry button, not an error alert.")
} catch APIError.status(401, _, _) {
    print("Token expired. Refresh it and try again.")
} catch APIError.status(let code, let body, _) {
    print("HTTP \(code):", String(decoding: body, as: UTF8.self))
} catch APIError.decoding(let error) {
    print(error)   // full path to the bad field, unlike localizedDescription
} catch {
    print("Unexpected:", error)
}

```

Printing the whole `DecodingError` instead of `error.localizedDescription` is the single most useful debugging habit here. The troubleshooting section shows why.

## Step 7: Configure timeouts and reuse one session

`URLSession.shared` is fine for scripts. An app should create one configured session at startup and reuse it everywhere.

```swift
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15    // max silence between packets, not total time
config.timeoutIntervalForResource = 60   // hard cap on the whole transfer
config.waitsForConnectivity = true       // wait for a network instead of failing instantly
config.requestCachePolicy = .reloadIgnoringLocalCacheData

let session = URLSession(configuration: config)

```

The comment on the first line is the part most tutorials get wrong. `timeoutIntervalForRequest` is an idle timer that resets every time data arrives, and it defaults to 60 seconds.

A slow download that trickles in one byte every 10 seconds never trips it. The total-time limit is `timeoutIntervalForResource`, and its default is seven days.

If you need "give up after 60 seconds, no matter what," set the resource timeout.

With `waitsForConnectivity` on, that same resource timeout caps how long a request waits for the device to come online.

Why reuse matters: each session owns its own connection pool, so a fresh session per request pays for a new TCP and TLS handshake every time.

Sessions with a delegate also hold a strong reference to that delegate until you call `invalidateAndCancel()` or `finishTasksAndInvalidate()`. Creating them in a loop leaks memory.

## Step 8: Run requests concurrently without flooding the server

Two independent requests should run side by side. `async let` starts both immediately and waits for both at the end.

```swift
func fetchTodo(id: Int) async throws -> Todo {
    let url = URL(string: "https://jsonplaceholder.typicode.com/todos/\(id)")!
    return try await send(URLRequest(url: url), as: Todo.self)
}

async let first = fetchTodo(id: 1)
async let second = fetchTodo(id: 2)
let (a, b) = try await (first, second)   // both requests are in flight at once

```

For a list of IDs, a task group is the usual answer.

The common version adds every ID at once. Pass it 500 IDs and it fires 500 simultaneous requests at the server.

This version keeps at most `limit` requests running and returns results in the original order:

```swift
func fetchTodos(ids: [Int], limit: Int = 4) async throws -> [Todo] {
    try await withThrowingTaskGroup(of: (Int, Todo).self) { group in
        var iterator = ids.makeIterator()
        var results: [Int: Todo] = [:]

        for _ in 0..<limit {                        // start the first `limit` requests
            guard let id = iterator.next() else { break }
            group.addTask { try await (id, fetchTodo(id: id)) }
        }
        while let finished = try await group.next() {
            results[finished.0] = finished.1
            if let id = iterator.next() {           // one finished, so start one more
                group.addTask { try await (id, fetchTodo(id: id)) }
            }
        }
        return ids.compactMap { results[$0] }        // original order, not finish order
    }
}

```

URLSession also caps parallel connections per host on its own (`httpMaximumConnectionsPerHost`, 4 by default on iOS and 6 on macOS).

The task-group limit still matters, because queued requests keep their timeout clocks running while they wait.

If one request throws, the group cancels the rest and rethrows. Catch inside `addTask` if you'd rather collect partial results.

## Step 9: Retry 429 and 5xx responses with backoff

Rate limits and brief server errors are normal. A retry loop with exponential backoff turns most of them into a short delay instead of a failure.

```swift
func sendWithRetry<T: Decodable>(_ request: URLRequest, as type: T.Type,
                                 maxAttempts: Int = 4) async throws -> T {
    for attempt in 1...maxAttempts {
        do {
            return try await send(request, as: type)
        } catch APIError.status(let code, _, let retryAfter)
                    where (code == 429 || code >= 500) && attempt < maxAttempts {
            // Use the server's Retry-After if it sent one, else back off exponentially
            let wait = retryAfter ?? pow(2, Double(attempt)) + .random(in: 0...1)
            try await Task.sleep(for: .seconds(min(wait, 30)))
        } catch APIError.transport(let error)
                    where error.code == .timedOut && attempt < maxAttempts {
            try await Task.sleep(for: .seconds(pow(2, Double(attempt))))
        }
    }
    throw URLError(.unknown)   // not reached: the last attempt's error propagates above
}

```

The `where attempt < maxAttempts` guard lets the final failure fall through to the caller untouched. The random jitter stops many clients from retrying in lockstep after an outage.

Two limits on this code. `Retry-After` can also be an HTTP date instead of seconds, which this version ignores and replaces with backoff.

And only retry requests that are safe to repeat. A retried POST can create two orders unless the API supports idempotency keys.

For the server side of rate limiting, see [what causes HTTP error 429](https://roundproxies.com/blog/http-error-429/).

## Step 10: Download files to disk

For anything larger than a few megabytes, `download(from:)` streams to a temporary file instead of holding the whole thing in memory.

```swift
let fileURL = URL(string: "https://httpbin.org/image/png")!
let (tempURL, response) = try await URLSession.shared.download(from: fileURL)

guard (response as? HTTPURLResponse)?.statusCode == 200 else {
    throw URLError(.badServerResponse)
}

// Move the temp file somewhere permanent before you do anything else
let destination = URL.documentsDirectory.appending(path: "sample.png")
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: tempURL, to: destination)

```

Unlike the completion-handler version, the async `download(from:)` doesn't delete the temp file for you. Move it or remove it, or temp storage slowly fills up.

`URL.documentsDirectory` needs iOS 16\. For downloads that must survive the app being suspended, you need a background session, which uses delegates instead of async/await.

## Send HTTP requests in Swift through a proxy

None of the top-ranking URLSession guides cover this, and it comes up constantly: testing geo-specific API responses, running scrapers from macOS, or inspecting your own traffic.

Since iOS 17 and macOS 14, `URLSessionConfiguration` accepts a [proxyConfigurations](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/proxyconfigurations) array from the Network framework. It handles HTTPS targets and proxy authentication, which the older API struggles with.

```swift
import Foundation
import Network

func makeProxiedSession(host: String, port: UInt16,
                        username: String, password: String) -> URLSession {
    let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host),
                                       port: NWEndpoint.Port(rawValue: port)!)
    var proxy = ProxyConfiguration(httpCONNECTProxy: endpoint)
    proxy.applyCredential(username: username, password: password)

    let config = URLSessionConfiguration.ephemeral   // fresh cookie jar per proxy identity
    config.proxyConfigurations = [proxy]
    return URLSession(configuration: config)
}

```

`httpCONNECTProxy` tunnels HTTPS through the proxy, so the proxy never sees your request contents.

For a SOCKS5 proxy, swap in `ProxyConfiguration(socksv5Proxy: endpoint)`. Not sure which one your provider gives you? [HTTP vs HTTPS vs SOCKS5 proxies](https://claude.ai/blog/http-vs-https-vs-socks5-proxies/) explains the difference.

Always verify the exit IP before trusting the setup. A misconfigured proxy fails silently by sending traffic direct.

```swift
struct IPResponse: Decodable { let origin: String }

let session = makeProxiedSession(host: "proxy.example.com", port: 8080,
                                 username: "user", password: "pass")
let request = URLRequest(url: URL(string: "https://httpbin.org/ip")!)

let ip = try await send(request, as: IPResponse.self, session: session)
print("Exit IP:", ip.origin)   // should be the proxy's IP, not your own

```

If you use Roundproxies residential or datacenter IPs, the host, port, username, and password from the dashboard drop straight into `makeProxiedSession`.

### Proxies on iOS 16 and older

Older systems only have `connectionProxyDictionary`. It works for unauthenticated proxies, with one trap: the `kCFNetworkProxiesHTTPS*` constants are macOS-only, so iOS code has to use the raw key strings.

```swift
let config = URLSessionConfiguration.ephemeral
config.connectionProxyDictionary = [
    kCFNetworkProxiesHTTPEnable as String: true,
    kCFNetworkProxiesHTTPProxy as String: "proxy.example.com",
    kCFNetworkProxiesHTTPPort as String: 8080,
    // kCFNetworkProxiesHTTPS* won't compile on iOS, so use the raw keys
    "HTTPSEnable": true,
    "HTTPSProxy": "proxy.example.com",
    "HTTPSPort": 8080
]
let legacySession = URLSession(configuration: config)

```

Authentication is the weak spot here. `Proxy-Authorization` is on Apple's reserved-header list, so adding it manually is unreliable.

Username keys in this dictionary are also widely reported on Apple's developer forums to be ignored for HTTPS traffic.

On these systems, authorize your device's IP with the proxy provider instead. [IP whitelisting vs username:password auth](https://roundproxies.com/blog/proxy-ip-whitelist-vs-user-pass/) covers the tradeoffs.

The same mechanism is how debugging proxies work. Point a session at [Charles Proxy](https://claude.ai/blog/how-to-use-charles-proxy/) on `127.0.0.1:8888` and you can read every request your app sends.

## URLSession vs Alamofire

[Alamofire](https://github.com/Alamofire/Alamofire) is built on top of URLSession, so everything above carries over. The question is whether its extras are worth a dependency.

|                           | URLSession                  | Alamofire                           |
| ------------------------- | --------------------------- | ----------------------------------- |
| Dependency                | None, ships with the OS     | Swift package                       |
| Status code validation    | Write it yourself (Step 6)  | .validate()                         |
| Retries and token refresh | Write it yourself (Step 9)  | RequestInterceptor                  |
| Multipart uploads         | Build the body by hand      | Built-in builder                    |
| Proxies                   | proxyConfigurations (above) | Same, via the session configuration |

My take: start with URLSession and the `send` function from Step 6\. That covers most apps with around 50 lines of code you fully understand.

Reach for Alamofire when you have dozens of endpoints that all need token refresh, retry rules, and multipart uploads. At that point you'd be rebuilding it anyway.

## Troubleshooting common URLSession errors

### "The resource could not be loaded because the App Transport Security policy requires the use of a secure connection."

**Why:** App Transport Security blocks plain `http://` URLs by default (`URLError` code -1022).

**Fix:** Use HTTPS. If you can't, add an exception for that one domain in `Info.plist` rather than turning ATS off everywhere:

```xml
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>legacy-api.example.com</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
        </dict>
    </dict>
</dict>

```

For a local dev server, `NSAllowsLocalNetworking` set to `true` is enough. `NSAllowsArbitraryLoads` works too, but App Review asks you to justify it.

### "The data couldn't be read because it isn't in the correct format."

**Why:** This is `DecodingError`'s `localizedDescription`, and it hides which field broke.

**Fix:** Catch the specific cases and print the coding path:

```swift
do {
    _ = try JSONDecoder().decode(Todo.self, from: data)
} catch let DecodingError.keyNotFound(key, context) {
    print("Missing key:", key.stringValue, "at", context.codingPath.map { $0.stringValue })
} catch let DecodingError.typeMismatch(type, context) {
    print("Expected \(type) at", context.codingPath.map { $0.stringValue })
} catch let DecodingError.valueNotFound(type, context) {
    print("Got null for \(type) at", context.codingPath.map { $0.stringValue })
} catch {
    print(error)
}

```

The usual culprit is a field that's sometimes `null`, a number sent as a string, or an error body you decoded because you skipped the status check.

### "The request timed out."

**Why:** `URLError.timedOut` (-1001). No data arrived within `timeoutIntervalForRequest`.

**Fix:** Check the URL and your connection first. Then confirm which timeout you meant to set (see Step 7), and retry with backoff (Step 9) instead of raising the number.

### "cancelled" (NSURLErrorDomain -999)

**Why:** The task was cancelled. In SwiftUI, `.task { }` cancels its work when the view disappears, and the request fails with `URLError.cancelled`.

**Fix:** This is usually correct behavior. Add a branch to the catch chain from Step 6 that ignores it:

```swift
} catch APIError.transport(let error) where error.code == .cancelled {
    return   // the view went away; nobody is waiting for this result
}

```

### "Cannot find 'URLSession' in scope" on Linux

**Why:** On Linux, URLSession lives in a separate module.

**Fix:** Add `import FoundationNetworking` below `import Foundation`, wrapped in `#if canImport(FoundationNetworking)` if the same file also builds for Apple platforms.

## FAQ

### Does URLSession throw an error on 404 or 500?

No. URLSession only throws for transport failures like no connection, DNS errors, or timeouts. Any HTTP status, including 404 and 500, returns normally, so check `HTTPURLResponse.statusCode` yourself.

### Should I create a new URLSession for every request?

No. Create one session per configuration and reuse it. Each session has its own connection pool, and sessions with delegates leak unless you invalidate them.

### How do I make a synchronous HTTP request in Swift?

Use `await` instead. In a command-line tool, top-level `await` in `main.swift` gives you straight-line code without a semaphore. In an app, blocking the main thread freezes the UI.

### What's the difference between data(from:) and data(for:)?

`data(from:)` takes a `URL` and always sends a GET with default settings. `data(for:)` takes a `URLRequest`, so you control the method, headers, body, and timeout.

### Can I use URLSession on Linux?

Yes, through swift-corelibs-foundation. Import `FoundationNetworking` alongside `Foundation`. A few Apple-only features, such as background sessions and `proxyConfigurations`, aren't available there.

## Wrapping up

The mental model to keep: URLSession moves bytes, and everything else is your job.

Check the status code, decode with a typed error, reuse one configured session, and retry only what's safe to repeat.

Start by copying the `send` function from Step 6 into your project and routing every request through it. Add the retry wrapper once you hit your first 429.

If you also work in Python, the same ideas map onto [making HTTP requests with Python Requests](https://roundproxies.com/blog/requests-python/). The full URLSession reference lives in [Apple's URLSessionConfiguration docs](https://developer.apple.com/documentation/foundation/urlsessionconfiguration).