Post

Creating a Swift Download Dependency with AsyncThrowingStream

Learn how to build a clean, testable Swift dependency for downloading files using AsyncThrowingStream.

Creating a Swift Download Dependency with AsyncThrowingStream

In this post, we’ll walk through how to create a modular, testable download client in Swift using AsyncThrowingStream, @DependencyClient, and a throttled progress mechanism.

Why AsyncThrowingStream?

AsyncThrowingStream allows you to emit values over time, which makes it a great fit for use cases like downloading files and reporting progress or errors as they occur.

Step 1: Define the DownloadClient

We define a dependency client using the new @DependencyClient macro:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Dependencies
import DependenciesMacros
import CasePaths
import Foundation

@DependencyClient
struct DownloadClient {
  var download: @Sendable (_ url: URL) -> AsyncThrowingStream<Event, any Error> = { _ in .finished() }

  @CasePathable
  enum Event: Equatable {
    case response(Data)
    case updateProgress(Double)
  }
}

Step 2: Add it to DependencyValues

This enables access via @Dependency(\.downloadClient):

1
2
3
4
5
6
extension DependencyValues {
  var downloadClient: DownloadClient {
    get { self[DownloadClient.self] }
    set { self[DownloadClient.self] = newValue }
  }
}

Step 3: Implement the Live Client

This is the real implementation using URLSession.shared.bytes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
extension DownloadClient: DependencyKey {
  static var liveValue: DownloadClient {
    .production
  }

  static var production: DownloadClient {
    .init { url in
      .init { continuation in
        Task {
          do {
            let (bytes, response) = try await URLSession.shared.bytes(from: url)
            var data = Data()
            var progress = 0
            for try await byte in bytes {
              data.append(byte)
              let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
              if newProgress != progress {
                progress = newProgress
                continuation.yield(.updateProgress(Double(progress) / 100))
              }
            }
            continuation.yield(.response(data))
            continuation.finish()
          } catch {
            continuation.finish(throwing: error)
          }
        }
      }
    }
  }

Step 4: Add Mocks for Preview and Test

1
2
3
4
5
6
7
  static var previewValue: DownloadClient { .mock }
  static var testValue: DownloadClient { .mock }

  static var mock: DownloadClient {
    .init { _ in .finished() }
  }
}

Throttle Progress Updates

We reduce UI work by only emitting progress when the integer percent changes:

1
2
3
4
5
let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
if newProgress != progress {
  progress = newProgress
  continuation.yield(.updateProgress(Double(progress) / 100))
}

This avoids flooding the UI with updates like 0.231 0.232 0.233... and instead yields at most 100 clean updates from 0.00 1.00.

Example Usage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Dependency(\.downloadClient) var downloadClient

func startDownload(from url: URL) async {
  do {
    for try await event in downloadClient.download(url) {
      switch event {
      case let .updateProgress(value):
        print("Progress: \(Int(value * 100))%")
      case let .response(data):
        print("Download completed, size: \(data.count) bytes")
      }
    }
  } catch {
    print("Download failed: \(error.localizedDescription)")
  }
}

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import Dependencies
import DependenciesMacros
import CasePaths
import Foundation

@DependencyClient
struct DownloadClient {
  var download: @Sendable (_ url: URL) -> AsyncThrowingStream<Event, any Error> = { _ in .finished() }

  @CasePathable
  enum Event: Equatable {
    case response(Data)
    case updateProgress(Double)
  }
}

extension DependencyValues {
  var downloadClient: DownloadClient {
    get { self[DownloadClient.self] }
    set { self[DownloadClient.self] = newValue }
  }
}

extension DownloadClient: DependencyKey {
  static var liveValue: DownloadClient { .production }

  static var production: DownloadClient {
    .init { url in
      .init { continuation in
        Task {
          do {
            let (bytes, response) = try await URLSession.shared.bytes(from: url)
            var data = Data()
            var progress = 0
            for try await byte in bytes {
              data.append(byte)
              let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
              if newProgress != progress {
                progress = newProgress
                continuation.yield(.updateProgress(Double(progress) / 100))
              }
            }
            continuation.yield(.response(data))
            continuation.finish()
          } catch {
            continuation.finish(throwing: error)
          }
        }
      }
    }
  }

  static var previewValue: DownloadClient { .mock }
  static var testValue: DownloadClient { .mock }

  static var mock: DownloadClient {
    .init { _ in .finished() }
  }
}

This approach makes your download logic modular, testable, and optimized for UI responsiveness. Perfect for SwiftUI + TCA projects.

☕ Support My Work

If you found this post helpful and want to support more content like this, you can buy me a coffee!

Your support helps me continue creating useful articles and tips for fellow developers. Thank you! 🙏

This post is licensed under CC BY 4.0 by the author.