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.organdjsonplaceholder.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.
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.
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:
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.
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.
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:
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.
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.
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.
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:
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.
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.
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:
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.
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.
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.
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 array from the Network framework. It handles HTTPS targets and proxy authentication, which the older API struggles with.
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 explains the difference.
Always verify the exit IP before trusting the setup. A misconfigured proxy fails silently by sending traffic direct.
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.
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 covers the tradeoffs.
The same mechanism is how debugging proxies work. Point a session at Charles Proxy on 127.0.0.1:8888 and you can read every request your app sends.
URLSession vs 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:
<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:
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:
} 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. The full URLSession reference lives in Apple's URLSessionConfiguration docs.