OnDevice Kit

Download a 2 GB model in the background on iOS with URLSession, mirrors and SHA-256

A language model is a 1.6 GB file that people will download over a hotel Wi-Fi and then close your app. This is how to make that download finish anyway, and make sure what arrived is the right file.

Shipping a local model inside your app bundle is rarely an option: LFM2 2.6B in 4-bit is 1,563,668,704 bytes, and nobody wants a 1.7 GB install for a feature they might not use. So the model is downloaded on request. A plain URLSession.shared.download(from:) works in the happy path and fails in all the common ones: the person switches apps, the phone locks, the connection drops at 80 %, the CDN returns an HTML error page with status 200 or 404.

This guide builds a download manager that handles those cases with a background URLSession, a list of mirrors and a checksum. It is about 150 lines and has no dependencies.

Describe the file you expect

Start with what makes a file the right file: its exact size and its SHA-256. Get both once on your Mac with ls -l and shasum -a 256.

Shell
ls -l LFM2-2.6B-Q4_K_M.gguf          # 1563668704
shasum -a 256 LFM2-2.6B-Q4_K_M.gguf  # 384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340df
ModelSpec.swift
import Foundation

struct ModelSpec: Sendable {
    let id: String
    let urls: [URL]          // tried in order: your mirror first, the original last
    let fileName: String
    let bytes: Int64         // for progress when the server sends no Content-Length
    let sha256: String

    static let lfm2 = ModelSpec(
        id: "lfm2-2.6b-q4_k_m",
        urls: [
            URL(string: "https://cdn.example.com/models/lfm2-2.6b-q4_k_m.gguf")!,
            URL(string: "https://huggingface.co/LiquidAI/LFM2-2.6B-GGUF/resolve/main/LFM2-2.6B-Q4_K_M.gguf")!,
        ],
        fileName: "lfm2-2.6b-q4_k_m.gguf",
        bytes: 1_563_668_704,
        sha256: "384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340df"
    )
}

Why a checksum, when HTTPS already protects the transfer? Because the failures you will see are not tampering. They are a mirror that serves a stale version, a proxy that truncates large responses, or a file that was replaced upstream. llama.cpp does not handle a cut-off GGUF gracefully; it can crash while loading it. Checking 1.6 GB takes a few seconds once and saves you that crash report.

A background session

A background session hands the transfer to a system daemon. It continues while your app is suspended, and if the app is terminated by the system it is relaunched in the background when the download finishes. Three settings matter:

ModelDownloader.swift
import Foundation
import CryptoKit
import Observation

@MainActor
@Observable
final class ModelDownloader: NSObject {
    enum State: Equatable {
        case notDownloaded, downloading(Double), verifying, ready, failed(String)
    }

    let spec: ModelSpec
    private(set) var state: State = .notDownloaded

    @ObservationIgnored private var session: URLSession!
    @ObservationIgnored private var task: URLSessionDownloadTask?
    @ObservationIgnored private var urlIndex = 0
    @ObservationIgnored static var completions: [String: () -> Void] = [:]

    nonisolated var identifier: String { "model.\(spec.id)" }

    nonisolated var file: URL {
        let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("Models", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir.appendingPathComponent(spec.fileName)
    }

    init(_ spec: ModelSpec) {
        self.spec = spec
        super.init()
        let config = URLSessionConfiguration.background(withIdentifier: identifier)
        config.isDiscretionary = false          // start now, not when iOS finds it convenient
        config.sessionSendsLaunchEvents = true  // relaunch the app when the transfer finishes
        session = URLSession(configuration: config, delegate: self, delegateQueue: .main)

        if FileManager.default.fileExists(atPath: file.path) { state = .ready; return }
        reattach()
    }
}

isDiscretionary = false is the one people miss. With true, iOS may wait for Wi-Fi and power before it even starts, which is correct for a prefetch and wrong for a download someone just tapped. The identifier must be stable across launches: it is how your new process finds the transfer the old one started.

Pick up a running download on launch

When the person reopens the app halfway through, the transfer is still running in the system. Creating the session with the same identifier reconnects it; you only need to ask which task is in flight and show its progress.

Swift
extension ModelDownloader {
    private func reattach() {
        session.getAllTasks { [weak self] tasks in
            guard let running = tasks.first as? URLSessionDownloadTask else { return }
            Task { @MainActor in
                guard let self else { return }
                self.task = running
                self.urlIndex = Int(running.taskDescription ?? "0") ?? 0
                self.state = .downloading(self.fraction(running.countOfBytesReceived,
                                                         running.countOfBytesExpectedToReceive))
            }
        }
    }

    func download() {
        guard task == nil, state != .ready else { return }
        urlIndex = 0
        start()
    }

    private func start() {
        guard urlIndex < spec.urls.count else {
            state = .failed("The download didn't work. Check the connection and try again, ideally on Wi-Fi.")
            return
        }
        state = .downloading(0)
        task = session.downloadTask(with: spec.urls[urlIndex])
        task?.taskDescription = String(urlIndex)   // survives relaunch: which mirror is this?
        task?.resume()
    }

    private func next() {
        task = nil
        urlIndex += 1
        start()
    }

    private func fraction(_ written: Int64, _ expected: Int64) -> Double {
        let total = expected > 0 ? expected : spec.bytes
        return min(1, Double(written) / Double(max(total, 1)))
    }
}

taskDescription is the only place to keep state on the task itself, and it survives the app being killed. Storing the mirror index there means that if the first mirror fails after a relaunch, you still know which one to try next.

The delegate: status codes, moving the file, errors

Background sessions do not support completion handlers; everything arrives through the delegate. Two traps live in didFinishDownloadingTo. First, a 404 or 500 also "finishes" and hands you a file, containing an error page. Second, the temporary file is deleted as soon as the method returns, so you have to move it synchronously, before hopping to the main actor.

Swift
extension ModelDownloader: URLSessionDownloadDelegate {
    nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                                didWriteData _: Int64, totalBytesWritten written: Int64,
                                totalBytesExpectedToWrite expected: Int64) {
        Task { @MainActor in self.state = .downloading(self.fraction(written, expected)) }
    }

    nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                                didFinishDownloadingTo location: URL) {
        if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
            Task { @MainActor in self.finished(ok: false) }
            return
        }
        var target = file
        try? FileManager.default.removeItem(at: target)
        let ok = (try? FileManager.default.moveItem(at: location, to: target)) != nil
        if ok {
            var values = URLResourceValues()
            values.isExcludedFromBackup = true    // 1.6 GB does not belong in iCloud Backup
            try? target.setResourceValues(values)
        }
        Task { @MainActor in self.finished(ok: ok) }
    }

    nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error, (error as NSError).code != NSURLErrorCancelled else { return }
        Task { @MainActor in
            if (error as NSError).code == NSURLErrorNotConnectedToInternet {
                self.task = nil
                self.state = .failed("You're offline. Try again when you're connected.")
            } else {
                self.next()      // timeouts, resets, TLS errors: try the next mirror
            }
        }
    }
}

Excluding the file from backup is not optional politeness. App Review has rejected apps for putting large re-downloadable data where it is backed up, and the person's iCloud quota will thank you.

Verify SHA-256 off the main thread

Reading 1.6 GB into a Data to hash it would double your memory footprint at exactly the moment you are about to load a model. Stream it in 8 MB chunks instead, on a background task:

Swift
extension ModelDownloader {
    fileprivate func finished(ok: Bool) {
        task = nil
        guard ok else { next(); return }
        state = .verifying
        let file = self.file, expected = spec.sha256.lowercased()
        Task.detached(priority: .utility) {
            let good = Self.sha256(of: file) == expected
            await MainActor.run {
                if good { self.state = .ready }
                else {
                    try? FileManager.default.removeItem(at: file)
                    self.next()   // wrong or broken file from this source: try the next one
                }
            }
        }
    }

    nonisolated static func sha256(of url: URL) -> String? {
        guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
        defer { try? handle.close() }
        var hasher = SHA256()
        while let chunk = try? handle.read(upToCount: 8 << 20), !chunk.isEmpty {
            hasher.update(data: chunk)
        }
        return hasher.finalize().map { String(format: "%02x", $0) }.joined()
    }
}

A failed checksum moves on to the next URL, exactly like an HTTP error. That is what makes mirrors useful: your own server can be the fast first choice, and if it ever serves a bad file, the original on Hugging Face still gets the person a working model.

Forward background events from the app delegate

When the download finishes while your app is not running, iOS launches it in the background and calls the app delegate. You must keep the completion handler and call it once the session has delivered all its events, or iOS will stop granting you background time.

App.swift
import SwiftUI

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        guard identifier.hasPrefix("model.") else { completionHandler(); return }
        ModelDownloader.completions[identifier] = completionHandler
    }
}

extension ModelDownloader {
    nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        let id = session.configuration.identifier ?? ""
        Task { @MainActor in ModelDownloader.completions.removeValue(forKey: id)?() }
    }
}

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
    var body: some Scene { WindowGroup { ContentView() } }
}

For the relaunched process to receive those delegate calls, a ModelDownloader with the same identifier has to exist. Create it early, in your app's root view or a shared model object, not lazily on the settings screen.

The UI

Swift
struct ModelRow: View {
    let downloader: ModelDownloader

    var body: some View {
        switch downloader.state {
        case .notDownloaded:      Button("Download (1.6 GB)") { downloader.download() }
        case .downloading(let p): ProgressView(value: p) { Text("Downloading \(Int(p * 100)) %") }
        case .verifying:          Text("Checking the file …")
        case .ready:              Label("Ready", systemImage: "checkmark.circle")
        case .failed(let reason): Text(reason)
        }
    }
}

Show the size before the download starts and make the download a choice. Both are good manners and both come up in App Review. Once the file is ready, load it with llama.cpp; if prompts get long, read the n_batch crash guide before your users find it.

Testing it

  • Start the download, then stop the app from Xcode. Relaunch: the progress should continue from where it is, not from zero.
  • Put an invalid URL first in urls, then a URL to a small wrong file. Both should fall through to the working mirror.
  • Turn on Airplane Mode mid-download and check the message, then retry.
  • Background relaunch is easiest to observe on a real device with the debugger detached; Xcode keeps the app alive otherwise.