Skip to content

Uploads Downloads and Progress

Husnain Ali edited this page Sep 27, 2026 · 3 revisions

Uploads, Downloads and Progress

Multipart upload

var form = MultipartFormData()
form.append("A caption", name: "caption")
form.append(jpegData, name: "photo", fileName: "cat.jpg", mimeType: "image/jpeg")
form.append(fileURL, name: "video")           // streamed from disk, not loaded into memory

let created: Photo = try await client.upload(CreatePhoto(), from: .multipart(form)) { event in
    print(event.fraction ?? 0)                // 0.0 ... 1.0
}
  • RFC 7578 boundary construction, per-part Content-Type, Content-Disposition.
  • Large file parts stream from disk.
  • Uploads are not retried and skip the 401-refresh hop; a partial upload is unsafe to replay. Response interceptors still run (a .retry outcome is ignored, since a transfer is never re-sent).
  • An in-memory body can also be sent with .data or .file:
try await client.upload(PutAvatar(), from: .data(pngData))
try await client.upload(PutAvatar(), from: .file(localURL))

File download

let destination = URL.documentsDirectory.appending(path: "report.pdf")

let fileURL = try await client.download(GetExport(), to: destination) { event in
    print("\(event.completed) / \(event.total) bytes")
}
  • Pass to: for a specific destination, or omit it to get the transport's temp file URL (move it before the next run).
  • Cancellation via Task cancellation or request ID, same as any request.
  • Background URLSession transfers need app-side wiring and are not handled automatically. See Platform Notes Thread Safety and Performance.
  • If a response interceptor fails the transfer, or the finished file cannot be moved out of URLSession's temporary location, the caller never gets a URL to a file that doesn't exist.

Progress tracking

Both upload and download take a trailing @Sendable (ProgressEvent) -> Void closure.

public struct ProgressEvent: Sendable, Hashable {
    public let completed: Int64
    public let total: Int64            // -1 if the server sent no Content-Length
    public var fraction: Double?       // completed / total, or nil when total is unknown
}

The closure is called on an arbitrary executor; hop to the main actor for UI:

try await client.download(GetExport(), to: dest) { event in
    Task { @MainActor in self.progress = event.fraction ?? 0 }
}

For Combine, uploadPublisher / downloadPublisher emit .progress(ProgressEvent) values then a final .finished(...). See Combine and SwiftUI.

Best practice: hop progress and completion callbacks to @MainActor before touching UI state.

Clone this wiki locally