We maintain a large file-sync app. After our upload endpoint moved to HTTP/2, we found that when multiple NSURLSessionUploadTasks run concurrently, all tasks send their request headers immediately (multiplexed on a single connection — confirmed identical localPort via URLSessionTaskMetrics), but request bodies are transmitted essentially one task at a time: while one task's body saturates the uplink, the other tasks send zero body bytes for the entire duration (countOfBytesSent == 0).
This reproduces with both background sessions (BackgroundUploadTask) and default sessions (LocalUploadTask), on Wi-Fi and cellular. iOS 26.5, Xcode 26.3, tasks created with uploadTask(with:fromFile:), multipart POST.
This becomes a hard failure behind a load balancer with slow-POST (RUDY) protection: requests whose first body KB doesn't arrive within 5s are rejected with 408. The starved tasks fail even though the network is healthy. For comparison, OkHttp on Android writes bodies in interleaved 16KB DATA frames under identical conditions, so all streams pass the first-KB check.
Metrics excerpt (4 concurrent uploads, one h2 connection):
task 1: duration 18.7s, sent 180MB (full line rate)
task 2: duration 22.8s, sent 43MB (transmitted only after task 1 finished)
task 3: duration 46.0s, sent 247MB (after task 2)
task 4: duration 5.2s, sent 0 bytes → 408 from the gateway
In one run a starved stream sent exactly 65,536 bytes (the default initial stream window) and then stalled.
Questions:
Is this sender-side scheduling (no round-robin between streams' DATA frames) the expected CFNetwork behavior? Does URLSessionTask.priority influence HTTP/2 stream weighting for upload bodies? Is there any other way to influence bandwidth sharing between concurrent uploads? Is there any supported way to opt out of HTTP/2 (constrain ALPN to HTTP/1.1) or cap concurrent streams per connection from the client side? We believe there isn't, but would like to confirm. What is the recommended pattern for concurrent large uploads in this situation?
Filed as FB24062619 with full sanitized metrics attached. Happy to provide more data.
That number stayed at two across six runs while we varied the session type
You might actually see a difference here if the device had other background transfers happening at the same time. But yeah, if the device is otherwise idle then I wouldn’t expect to see a difference between session types.
HTTPMaximumConnectionsPerHost (2 and 6)
That option is only relevant for HTTP/1.
Hmmm, a few years ago I filed a bug requesting that we document that fact (r. 98788661), but it’s not been fixed. I think I might just fix it myself (-:
and how many tasks were queued (4 and 12).
Again, that’s not surprising because you’re clearly bumping into some internal limit that’s unrelated to the task count.
Since only a client-side flag changed, the limit does not appear to come from the server.
Right. My reading of the response you got via FB24062619 is that it was focused on the modern loader. The classic loader has different constraints.
This still matters to us
If I were in your shoes I’d drop support for iOS 17. The modern loader is Apple’s focus, and there’s little we can do to help you with the classic loader.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"