---
title: "Swift Web Scraping: URLSession, Alamofire, SwiftSoup, and Kanna"
description: "Scrape sites in Swift: URLSession vs Alamofire, JSON with Codable, HTML parsing via SwiftSoup and Kanna, redirects, WKWebView limits, and App Store rules."
url: https://webscraping.ai/blog/swift-web-scraping
markdown_index: https://webscraping.ai/llms.txt
---
# Swift Web Scraping: URLSession, Alamofire, SwiftSoup, and Kanna

Swift is not the language you would pick to build a scraping pipeline from scratch — Python and Node have a decade more tooling. But plenty of Swift developers need to pull data off a page anyway: an iOS app reading a site that never shipped an API, a macOS menu-bar tool that watches a price, a [Vapor](https://vapor.codes/) service whose whole stack is already Swift. This guide covers that job honestly: which HTTP client to use, how to decode what comes back, how to parse HTML with SwiftSoup or Kanna, what WKWebView can and can't do for JavaScript-rendered pages, and what App Review will say about the result.

Everything here targets Swift 6 and Alamofire 5.12.

## Key Takeaways

- Modern `URLSession` with `async/await` covers most scraping needs with zero dependencies — reach for Alamofire when you want its interceptors, retries, and validation, not by reflex
- `responseDecodable` / `serializingDecodable` with `Codable` is the current Alamofire JSON idiom; `responseJSON` is deprecated and slated for removal in Alamofire 6
- **SwiftSoup** is a Swift port of Java's jsoup — CSS selectors, pure Swift, works on Linux. **Kanna** wraps libxml2 and adds XPath
- Redirects are followed automatically by both clients; to inspect or block them use Alamofire's `Redirector` or `URLSessionTaskDelegate`
- WKWebView can render JavaScript pages, but it needs a UI process, runs on the main actor, and doesn't exist server-side — it's a per-page tool, not a pipeline
- App Review rejects thin website wrappers under Guideline 4.2, and a scraper that's broken on review day is a rejected build

## Does this job belong in Swift at all?

Worth asking before you write any code, because the answer is often no.

**Swift is a reasonable choice when:**

- The scraping happens _inside_ an app you're already shipping — an iOS client pulling a schedule off a site with no public API, where round-tripping through your own backend isn't worth the operational cost
- You're building a macOS tool or CLI and want a single compiled binary with no runtime to install
- Your backend is Swift already (Vapor, Hummingbird) and adding a Python service is the bigger cost

**Swift is the wrong choice when:**

- You need a crawler with scheduling, deduplication, and retry semantics — that's [Scrapy's](https://webscraping.ai/blog/scrapy-web-scraping) job, and nothing in Swift is close
- The target renders everything client-side and you need it at scale — see the WKWebView section below for why
- You need proxy rotation, CAPTCHA handling, and fingerprint management. `URLSession`'s proxy support on Apple platforms is awkward at best (`connectionProxyDictionary` is unofficial-feeling and inconsistently honored), and none of the anti-blocking ecosystem exists in Swift

A common and sensible middle ground: keep the _fetching_ problem out of the app entirely and call an HTTP API that returns rendered HTML or extracted fields, so the Swift side is just `URLSession` and `Codable`. That's the pattern in the last section.

## HTTP: URLSession or Alamofire?

Since iOS 15 / macOS 12, `URLSession` has async/await built in, and that closed most of the gap that made Alamofire feel mandatory:

```swift
let url = URL(string: "https://example.com/products")!
var request = URLRequest(url: url)
request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", forHTTPHeaderField: "User-Agent")
request.timeoutInterval = 30

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

guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
    throw ScrapeError.badStatus
}
let html = String(decoding: data, as: UTF8.self)
```

That's the whole fetch. No dependency, no build-time cost, and it works identically on Linux through swift-corelibs-foundation.

**What Alamofire actually adds** , once you look past the nicer syntax:

| Feature | Why it matters for scraping |
| --- | --- |
| `RequestInterceptor` (`adapt` + `retry`) | One place to inject headers, refresh a token, and retry 429/5xx with backoff — the hand-rolled `URLSession` equivalent is real code |
| `.validate()` | Turns non-2xx into a `.failure` instead of a status code you have to remember to check |
| `RetryPolicy` / `ConnectionLostRetryPolicy` | Ready-made exponential backoff with jitter |
| `EventMonitor` + `.cURLDescription` | Prints the exact cURL command for a request — the fastest way to answer "why does it work in the terminal but not the app" |
| `Redirector` | Declarative redirect policy per request (see below) |
| `ServerTrustManager` | Certificate pinning without writing a delegate |
| `MultipartFormData` | File uploads without assembling boundaries by hand |

**What Alamofire does not give you:** background transfers. Alamofire explicitly does not support background `URLSession` configurations, so downloads that must survive app suspension have to use `URLSession` directly.

Rule of thumb: a handful of requests, use `URLSession`. Retry policy, auth refresh, and request logging across many endpoints, use Alamofire and stop re-implementing them.

## Installing Alamofire

Swift Package Manager, in `Package.swift`:

```swift
dependencies: [
    .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.12.0")
]
```

Or in Xcode: **File → Add Package Dependencies** and paste `https://github.com/Alamofire/Alamofire.git`.

Alamofire 5.10 added full Swift concurrency support with `Sendable` requirements, and 5.11 raised the floor to Xcode 16 and the Swift 6 compiler. If you're upgrading an older project and see a wall of concurrency warnings, that's the cause — most public APIs carry `@preconcurrency` for compatibility, but your own captured state has to be `Sendable`.

```swift
import Alamofire
```

## JSON responses with Alamofire

This is what most people arrive for, so it's worth being precise about which API is current.

### Define Codable models

```swift
struct Product: Codable {
    let id: Int
    let name: String
    let price: Double
    let inStock: Bool
    let releasedAt: Date?

    enum CodingKeys: String, CodingKey {
        case id, name, price
        case inStock = "in_stock"
        case releasedAt = "released_at"
    }
}
```

### responseDecodable (closure-based)

```swift
AF.request("https://api.example.com/products/1")
    .validate()
    .responseDecodable(of: Product.self) { response in
        switch response.result {
        case .success(let product):
            print("\(product.name): $\(product.price)")
        case .failure(let error):
            print("Failed: \(error)")
        }
    }
```

### serializingDecodable (async/await — prefer this)

```swift
func fetchProduct(id: Int) async throws -> Product {
    try await AF.request("https://api.example.com/products/\(id)")
        .validate()
        .serializingDecodable(Product.self)
        .value
}
```

`.value` throws on failure, so the whole thing composes with `try await` and ordinary Swift error handling. Arrays work the same way — `serializingDecodable([Product].self)`.

### Don't use responseJSON

`responseJSON` hands you `Any` that you downcast by hand:

```swift
// Deprecated since Alamofire 5.5, slated for removal in Alamofire 6.
AF.request(url).responseJSON { response in
    if let json = response.value as? [String: Any] {
        let name = json["name"] as? String // stringly-typed, silently nil on drift
    }
}
```

It's deprecated, it loses type safety, and it turns a schema change into a `nil` rather than an error you can log. If you genuinely need to inspect unknown JSON, decode into a `JSONSerialization` object or a custom `AnyDecodable` explicitly — don't reach for a deprecated serializer to get there.

### Custom decoders

Snake-case keys and ISO 8601 dates are common enough that a shared decoder beats per-model `CodingKeys`:

```swift
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601

let product = try await AF.request(url)
    .validate()
    .serializingDecodable(Product.self, decoder: decoder)
    .value
```

### Errors that are actually informative

The failure you'll hit most is a `DecodingError` wrapped in `AFError.responseSerializationFailed`, usually because the server returned an HTML error page where JSON was expected. Unwrap it rather than printing `error.localizedDescription`, which flattens the useful part:

```swift
do {
    let product = try await AF.request(url).validate()
        .serializingDecodable(Product.self).value
} catch let error as AFError {
    switch error {
    case .responseValidationFailed(reason: .unacceptableStatusCode(let code)):
        print("HTTP \(code)")
    case .responseSerializationFailed(reason: .decodingFailed(let underlying)):
        print("Decoding failed: \(underlying)") // says which key, and why
    default:
        print("Request failed: \(error)")
    }
}
```

### Sending JSON

```swift
struct SearchQuery: Encodable { let term: String; let page: Int }

let results = try await AF.request(
    "https://api.example.com/search",
    method: .post,
    parameters: SearchQuery(term: "swift", page: 1),
    encoder: JSONParameterEncoder.default
).validate().serializingDecodable([Product].self).value
```

For form posts, swap the encoder for `URLEncodedFormParameterEncoder.default` — that's the `application/x-www-form-urlencoded` body most old-school HTML forms expect.

## Redirects

Both clients follow redirects automatically, up to a limit, which is usually what you want and occasionally exactly what you don't — login flows in particular, where the `Set-Cookie` on a 302 is the thing you're after.

**Alamofire** uses a `RedirectHandler`; the built-in `Redirector` covers the three useful policies:

```swift
// Follow (the default), don't follow, or rewrite the request mid-flight.
let session = Session(redirectHandler: Redirector(behavior: .doNotFollow))

let response = await session.request("https://example.com/login")
    .serializingData()
    .response

if let location = response.response?.headers["Location"] {
    print("Would have gone to \(location)")
}
```

`.modify` gives you the redirected `URLRequest` before it's sent, so you can strip a header, keep a cookie, or return `nil` to stop:

```swift
let redirector = Redirector(behavior: .modify { task, request, response in
    var request = request
    request.setValue("https://example.com/", forHTTPHeaderField: "Referer")
    return request
})
```

**URLSession** does the same through the delegate — note that returning `nil` for `newRequest` stops the redirect and hands you the 3xx response:

```swift
final class RedirectBlocker: NSObject, URLSessionTaskDelegate {
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    willPerformHTTPRedirection response: HTTPURLResponse,
                    newRequest request: URLRequest) async -> URLRequest? {
        nil
    }
}

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

Two things that surprise people: `URLSession` caps redirects at 20 and there's no public knob to change it, and both clients drop the `Authorization` header on a cross-host redirect. That's a security behavior, not a bug — if you need credentials to survive a hop, set them yourself in the modified request.

## Parsing HTML: SwiftSoup

`URLSession` and Alamofire give you a `String` of HTML. Regular expressions are not the next step — HTML isn't regular, and a nested tag or an unquoted attribute will break your pattern in a way that's invisible until production. Use a parser.

**SwiftSoup** is a direct port of Java's [jsoup](https://webscraping.ai/blog/jsoup-java-guide) — same API shape, same lenient parsing of real-world broken markup. It's pure Swift with no C dependencies, which means it also builds on Linux.

```swift
dependencies: [
    .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0")
]
```

Everything in SwiftSoup throws, because a bad selector is a runtime error:

```swift
import SwiftSoup

let doc = try SwiftSoup.parse(html)

// CSS selectors, same syntax you'd use in the browser console
let titles = try doc.select("h2.product-title")
for title in titles.array() {
    print(try title.text())
}

// Single element
if let price = try doc.select("span.price").first() {
    print(try price.text()) // "$29.00" — visible text, entities decoded
    print(try price.html()) // inner HTML
    print(try price.attr("data-cents")) // "" if absent, never nil
}

// Attributes and traversal
for link in try doc.select("a[href]").array() {
    let href = try link.attr("href")
    let absolute = try link.attr("abs:href") // resolved against the base URL
    print("\(try link.text()) -> \(absolute)")
}
```

`abs:href` only works if you gave the parser a base URL: `SwiftSoup.parse(html, "https://example.com/page")`. Without it you get the raw relative path, which is a quiet source of broken links downstream.

Three more things worth knowing:

- **Selector support is jsoup's** , which is broader than CSS: `:contains(text)`, `:matches(regex)`, `:has(> img)`, and `:eq(n)` all work. Our [CSS selectors cheat sheet](https://webscraping.ai/blog/css-selectors-cheat-sheet) covers the standard ones.
- **`text()` vs `ownText()`** — `text()` includes descendants, `ownText()` doesn't. Scraping a `<li>Price: <b>$29</b></li>`, `text()` gives `"Price: $29"` and `ownText()` gives `"Price:"`.
- **SwiftSoup also sanitizes.** `try SwiftSoup.clean(userHTML, Whitelist.basic())` strips scripts and unsafe attributes — useful if you're rendering scraped HTML anywhere.

## Parsing HTML: Kanna and XPath

**Kanna** takes the other approach: it wraps libxml2, so it's fast, battle-tested, and — the reason to pick it — supports **XPath** as well as CSS.

```swift
dependencies: [
    .package(url: "https://github.com/tid-kijyun/Kanna.git", from: "6.1.0")
]
```

```swift
import Kanna

let doc = try HTML(html: html, encoding: .utf8)

// CSS
for item in doc.css("div.product") {
    print(item.at_css("h2")?.text ?? "")
}

// XPath — the thing SwiftSoup can't do
for node in doc.xpath("//table[@id='prices']//tr[position() > 1]/td[2]") {
    print(node.text ?? "")
}

// Axes: select by a sibling's content, which CSS has no way to express
let value = doc.at_xpath("//th[text()='SKU']/following-sibling::td[1]")?.text
```

That last selector is the case for Kanna in one line. Spec tables and definition lists are laid out as "label cell, then value cell," and CSS has no sibling-by-content axis. If your targets are table-heavy, Kanna will save you a lot of index arithmetic. Our [XPath cheat sheet](https://webscraping.ai/blog/xpath-cheat-sheet) has the full axis syntax.

Kanna's API returns optionals rather than throwing (`node.text` is `String?`, `node["href"]` is `String?`), which reads more naturally in Swift but makes it easier to swallow a selector that silently matched nothing. Assert on empty results in tests.

| | SwiftSoup | Kanna |
| --- | --- | --- |
| Backing | Pure Swift (jsoup port) | libxml2 |
| Selectors | CSS + jsoup extensions | CSS **and XPath** |
| Broken HTML | Very lenient, jsoup's recovery rules | libxml2's recovery, also good |
| Errors | Throws | Returns optionals |
| Linux | Yes, no system deps | Yes, needs libxml2 |
| Mutation | Full DOM edit + sanitizer | Read-oriented |

Pick SwiftSoup by default; add Kanna when you want XPath.

## A complete scraper

Putting the pieces together — `URLSession` for fetching, SwiftSoup for parsing, structured concurrency for a handful of pages at once. No Alamofire here, deliberately, to show how little you need:

```swift
import Foundation
import SwiftSoup

struct Article: Sendable {
    let title: String
    let url: String
    let summary: String
}

enum ScrapeError: Error {
    case badStatus(Int)
    case notHTML
}

func fetchHTML(_ url: URL) async throws -> String {
    var request = URLRequest(url: url)
    request.setValue("Mozilla/5.0 (compatible; MyApp/1.0)", forHTTPHeaderField: "User-Agent")
    request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
    request.timeoutInterval = 20

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse else { throw ScrapeError.notHTML }
    guard (200..<300).contains(http.statusCode) else {
        throw ScrapeError.badStatus(http.statusCode)
    }
    return String(decoding: data, as: UTF8.self)
}

func parseArticles(_ html: String, baseURL: String) throws -> [Article] {
    let doc = try SwiftSoup.parse(html, baseURL)
    return try doc.select("article.post").array().compactMap { element in
        guard let link = try element.select("h2 a").first() else { return nil }
        return Article(
            title: try link.text(),
            url: try link.attr("abs:href"),
            summary: try element.select("p.excerpt").first()?.text() ?? ""
        )
    }
}

func scrape(pages: [URL]) async throws -> [Article] {
    try await withThrowingTaskGroup(of: [Article].self) { group in
        for page in pages {
            group.addTask {
                let html = try await fetchHTML(page)
                try await Task.sleep(for: .milliseconds(500)) // be polite
                return try parseArticles(html, baseURL: page.absoluteString)
            }
        }
        return try await group.reduce(into: []) { $0 += $1 }
    }
}
```

A few notes on the concurrency, because this is where Swift 6 will argue with you. `Article` is `Sendable` so it can cross the task boundary; `SwiftSoup.Document` is not, which is why parsing happens inside the task and only the value types come out. And a task group with no limit will fire every request simultaneously — for more than a dozen URLs, add a semaphore or chunk the input. Ten parallel requests at a site that expected one is how a scraper becomes a rate-limit problem.

Two hardening notes:

- `String(decoding:as:)` assumes UTF-8. Pages that declare `charset=iso-8859-1` will come out with mojibake; read the charset from the `Content-Type` header or the `<meta>` tag and use `String(data:encoding:)` with the right `String.Encoding`.
- Cookies persist automatically in `HTTPCookieStorage.shared`, which is convenient for session-based logins and surprising when a stale session breaks a later run. For isolation, build a session with an ephemeral configuration.

## JavaScript-rendered pages, and WKWebView's real limits

If the HTML you fetch contains an empty `<div id="root">`, the content is rendered client-side and no amount of header tweaking will produce it. On Apple platforms, **WKWebView** is the built-in answer:

```swift
@MainActor
final class PageRenderer: NSObject, WKNavigationDelegate {
    private let webView = WKWebView(frame: .zero)
    private var continuation: CheckedContinuation<String, Error>?

    func html(from url: URL) async throws -> String {
        webView.navigationDelegate = self
        webView.load(URLRequest(url: url))
        return try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
        }
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        Task {
            let html = try? await webView.evaluateJavaScript(
                "document.documentElement.outerHTML"
            ) as? String
            continuation?.resume(returning: html ?? "")
            continuation = nil
        }
    }
}
```

That works. Here's what it costs, stated plainly:

- **`didFinish` is not "content ready."** It fires when navigation completes, not when the app's data has loaded. Most single-page apps need a polling loop that waits for a selector to appear — and the timeout you pick becomes a source of flaky results.
- **It's main-actor-bound and UI-process-backed.** Each `WKWebView` spawns a content process. A few are fine; dozens will exhaust memory, and on iOS a backgrounded app has its web content processes killed.
- **Off-screen web views throttle.** WebKit deprioritizes rendering for views not in the hierarchy, so a `WKWebView` you never add to a window may never finish laying out. The usual workaround is a zero-alpha view in the hierarchy, which is exactly as fragile as it sounds.
- **It does not exist on Linux.** Server-side Swift has no WKWebView, and no Swift-native headless browser. Your options there are shelling out to a [headless browser](https://webscraping.ai/blog/headless-browser-guide) in another runtime, or an HTTP API.
- **No proxy rotation, no fingerprint control.** WKWebView will happily render a bot-detection page for you.

For one page on demand — a share extension pulling metadata, a macOS tool the user triggers — WKWebView is the right tool. For anything continuous, it isn't.

## App Store review considerations

If the scraper ships inside an App Store app, the review guidelines matter as much as the code. Briefly, and honestly:

- **Guideline 4.2, Minimum Functionality.** An app that is mostly a repackaged website gets rejected. Scraping a site and displaying it needs to add something the site doesn't — offline access, notifications, aggregation across sources, a native interaction the web version lacks.
- **Guideline 5.2, Intellectual Property.** Displaying another company's content, and especially their branding, without permission is a rejection risk and a legal one. This bites hardest for apps built around a single well-known site.
- **The target site's terms of service** are a separate question from Apple's rules, and scraping doesn't stop being a contract issue because it happens in an app. Our overview of [whether web scraping is legal](https://webscraping.ai/blog/is-web-scraping-legal) covers the general shape; it isn't legal advice.
- **App Review runs your app on a real device on a real day.** If your selectors broke because the site redesigned last week, the reviewer sees an empty screen and rejects the build. This is the practical argument for a server-side layer between the app and the site: you can fix a selector in an afternoon, but an app update takes days.
- **App Transport Security requires HTTPS.** Scraping an HTTP-only site needs an ATS exception in `Info.plist`, and exceptions get scrutinized at review. Have a justification.

None of this is a reason not to ship. It's a reason to design so that a site change is a server-side fix, not an emergency App Store submission.

## Comparison: how to fetch

| Approach | Handles JS | Runs on Linux | Best for |
| --- | --- | --- | --- |
| `URLSession` | No | Yes | Default. Static HTML and JSON APIs, no dependency |
| Alamofire | No | Yes | Retries, interceptors, auth refresh, request logging |
| WKWebView | Yes | No | One rendered page, on demand, in an Apple-platform app |
| Scraping API | Yes | Yes | Continuous scraping, JS rendering, rotating proxies, without shipping a browser |

## Keeping the fetching problem out of your app

The two walls every Swift scraper hits are the same two everywhere: JavaScript rendering and getting blocked. Neither has a good in-app answer — you can't ship a headless browser in an iOS app, and one device is one IP.

[WebScraping.AI](https://webscraping.ai/) handles both behind an HTTP API, which keeps the Swift side to `URLSession` and `Codable`:

```swift
struct ProductFields: Codable {
    let name: String
    let price: String
}

func extract(from target: String) async throws -> ProductFields {
    var components = URLComponents(string: "https://api.webscraping.ai/ai/fields")!
    components.queryItems = [
        .init(name: "api_key", value: apiKey),
        .init(name: "url", value: target),
        .init(name: "fields[name]", value: "Product name"),
        .init(name: "fields[price]", value: "Price with currency symbol")
    ]

    let (data, _) = try await URLSession.shared.data(from: components.url!)
    return try JSONDecoder().decode(ProductFields.self, from: data)
}
```

`/ai/fields` returns structured data and skips parsing entirely, which also removes the selector-fragility problem described in the App Review section above. If you'd rather keep parsing in Swift, [`/html`](https://webscraping.ai/docs) returns the fully rendered page for SwiftSoup, and `/selected` returns just the fragment a CSS selector matches — a small enough response to decode on a phone.

With Alamofire, the same call is a `parameters` dictionary:

```swift
let fields = try await AF.request(
    "https://api.webscraping.ai/ai/fields",
    parameters: [
        "api_key": apiKey,
        "url": target,
        "fields[name]": "Product name",
        "fields[price]": "Price with currency symbol"
    ]
).validate().serializingDecodable(ProductFields.self).value
```

It's the division of labor that tends to survive: the network and anti-blocking problem on the server, Swift doing what it's good at — type-safe handling of the results in a native app. Whether that's [price monitoring](https://webscraping.ai/use-cases/price-monitoring) or feeding a [RAG knowledge base](https://webscraping.ai/use-cases/rag-knowledge-base), the app never has to know a site redesigned.

## Frequently asked questions

**Should I use Alamofire or URLSession in 2026?** Start with `URLSession`. Since async/await landed it handles requests, headers, timeouts, cookies, and JSON decoding in a few lines with no dependency. Move to Alamofire when you find yourself writing retry logic, a header-injection layer, or per-request validation by hand — those are the pieces it genuinely replaces, and reimplementing them badly is the real cost of avoiding it.

**How do I parse HTML in Swift?** Use SwiftSoup for CSS selectors (a port of Java's jsoup, pure Swift, works on Linux) or Kanna if you need XPath. Both take an HTML string and give you a queryable document. Don't use regular expressions — nested tags and attribute quoting will break patterns in ways that only show up on the pages you didn't test.

**Why is my Alamofire request returning nil or an empty result?** Three usual causes. The response isn't JSON — a 403 HTML block page decodes to a `DecodingError`, so unwrap `AFError.responseSerializationFailed` and read the underlying error. Your `Codable` keys don't match the payload — add `.convertFromSnakeCase` or explicit `CodingKeys`. Or the page renders its content with JavaScript, in which case the HTML you got genuinely doesn't contain the data.

**Can I scrape a JavaScript-heavy site from an iOS app?** With WKWebView, one page at a time, in the foreground, with a hand-written wait for the content you need. It works for user-triggered lookups. It doesn't work as a background pipeline: web content processes get killed when the app backgrounds, off-screen web views throttle, and there's no proxy control. For anything continuous, render server-side and let the app fetch a result.

**Does Alamofire support async/await?** Yes. Use the `serializing*` family — `serializingDecodable(_:)`, `serializingString()`, `serializingData()` — and read `.value` to get the decoded result or throw, or `.response` for the full response including headers and status. The closure-based `response*` methods still work and are not deprecated; the async ones are just less code.

**How do I stop Alamofire from following a redirect?** Build a `Session` with `Redirector(behavior: .doNotFollow)`, or attach `.redirect(using:)` to a single request. Use `.modify` when you want to inspect or rewrite the redirected request instead of blocking it — that's the hook for keeping a cookie or adding a `Referer` across the hop. Note that the `Authorization` header is dropped on cross-host redirects by design.

**Can I do web scraping in server-side Swift?** Yes for static HTML: `URLSession` and SwiftSoup both work on Linux, and SwiftSoup has no system dependencies. What's missing is the rendering layer — there's no WKWebView and no Swift-native headless browser — so JavaScript-rendered targets need either a browser in another runtime or a rendering API.
