Concurrent upload tasks over HTTP/2: request bodies sent strictly sequentially (no stream interleaving) — starved tasks fail behind slow-POST protection

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.

I’d like you to add a sysdiagnose log to your bug report (FB24062619). Specifically:

  1. On a test device, install the CFNetwork debug profile, available from our Bug Reporting > Profiles and Logs page.
  2. Reproduce the problem.
  3. Immediately after that, trigger a sysdiagnose log.
  4. Add the resulting log to your bug.
  5. Reply back here when you’re done.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@DTS Engineer

Thanks Quinn - the sysdiagnose is now attached to FB24062619.

It was captured on 2026-08-03 with the CFNetwork debug profile installed, right after a reproduction. I noted the process name and host to look for in the Feedback itself. The frame-level trace was very helpful: in that window our app logged 119 "sent HEADERS frame" entries against 41 "sent DATA frame" entries, which matches what we had been seeing through URLSessionTaskMetrics.

What the trace confirms is narrower than what I first posted. Under the classic loader, exactly two of the concurrent upload tasks transmit their request body, and the rest stay at countOfRequestBodyBytesSent = 0 until they are cut off. That number stayed at two across six runs while we varied the session type, HTTPMaximumConnectionsPerHost (2 and 6), and how many tasks were queued (4 and 12). The streams that never sent a body still received HTTP responses, so the server had accepted their HEADERS and its advertised stream limit was above two.

Setting usesClassicLoadingMode = NO changes it - all four then transmit, at 1.80 / 1.46 / 1.79 MB/s. Since only a client-side flag changed, the limit does not appear to come from the server.

So the question I am still stuck on is where that "2" comes from. Is there a limit on concurrent request bodies per HTTP/2 connection in the classic loader, and is it something an app can influence? I got an answer on the Feedback that stream scheduling is not controllable and that the server determines the stream count, but that does not seem to explain the behavior above, so I may well be misreading something.

This still matters to us even with the new loader available: our minimum deployment target is iOS 17.0, so a portion of our users cannot get that flag at all, and I would like to understand what we are working around before choosing a fallback for them.

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"

Concurrent upload tasks over HTTP/2: request bodies sent strictly sequentially (no stream interleaving) — starved tasks fail behind slow-POST protection
 
 
Q