Streams that fork
AI Use Disclosure
The ideas here are my own. I use AI tools in various parts of the writing process, including summarizing my notes and prompts, improving grammar, tightening wording, and checking whether an argument is clear. These posts are as much notes for myself as they are for anyone else who is interested.
I think we are designing message protocols the wrong way around. Or more precisely: the APIs we hand to developers for them.
We have a well-known formula for how much faster a program gets when you throw more processors at it. The same reasoning applies to transport protocols, and I rarely see it used when people decide what the programming interface should look like.
Amdahl, but for bytes
Amdahl’s law: if a fraction s of your work is inherently
serial, no number of processors gets you past a speedup of
1 / s.
speedup(n) = 1 / (s + (1 - s) / n)
Ten percent serial work, and you will never be more than ten times faster. Doesn’t matter how many cores you buy.
Transport has the same split.
TCP gives you one ordered stream of bytes. Everything waits for everything before it. Lose one packet and every byte after it is held back until the retransmission arrives, even bytes that have nothing to do with the lost one. That is head-of-line blocking; the transport version of running a parallel workload on one core.
UDP gives you independent datagrams. Nothing waits for anything. The transport version of “here are a thousand threads, good luck”.
So the interesting question isn’t which one is better. It is: what fraction of an actual exchange needs ordering, and what fraction could proceed independently?
The analogy isn’t exact. On a clean link one ordered stream is about as fast as many independent ones. What independence buys you is latency under loss and reordering: how long unrelated data sits waiting behind a packet it never needed. But the shape holds. The part that must be ordered sets the floor, and everything else is only as independent as you let the transport know it is.
Most exchanges have a serial head and a parallel tail
Look at what real network exchanges do.
- A web page: fetch the HTML, parse it, discover forty resources, fetch those.
- A git fetch: negotiate which commits each side has, then send the objects.
- A sync protocol: agree on what we are syncing and what each side already has, then move a pile of blobs.
- A backup, a package install, a video player: a manifest first, then chunks.
Almost always the same pattern:
1. a short ordered conversation to decide *what*
2. a set of mostly independent transfers of *that*
The first part needs ordering. A reply only makes sense after the question. The second part mostly doesn’t. Blob 17 does not need to wait for blob 16.
The “serial fraction” of a network exchange is usually small and comes first. After that, the work fans out.
QUIC swings back toward messages
For a long time the practical answer was: use TCP, and if you need parallelism open more TCP connections. Browsers opened six per host. HTTP/2 then multiplexed many streams over one TCP connection, which fixed some things and made head-of-line blocking worse in others: one lost packet now stalled every stream.
QUIC moves the multiplexing into the transport. One connection carries many independent streams, and loss on one doesn’t block the others. There is also an unreliable datagram extension if you want UDP-like messages inside the same connection.
That is a real swing back toward the message end of the spectrum. The transport finally lets independent things be independent.
And yet a lot of QUIC usage still looks like one stream doing everything. SMB over QUIC, for example, uses a single stream per connection according to Microsoft’s answer on the Samba protocol list. The protocols that do use many streams, like HTTP/3 or DNS over QUIC, already had natural independent units (requests, queries) to map onto them. Outside of that you have to design for it; the IETF’s own applicability guide leaves the mapping of data to streams to each application.
Most developers don’t do that design work. Not because they are lazy, but because the API makes it hard.
The obvious API is the wrong one
If you are writing a QUIC library, the obvious API is the one that exposes everything the protocol can do:
connection
open_bidirectional_stream()
open_unidirectional_stream()
accept_stream()
send_datagram()
recv_datagram()
...per-stream priorities, flow control, resets, stop-sending...
Honest. Complete. Also a bag of parts.
Now the developer has to answer questions TCP never made them answer. Which side opens which stream? How does the receiver know what a newly accepted stream is for? What happens if stream 5 arrives before the message on stream 0 that explains it? Where do I put the ID that ties a blob back to the request that asked for it?
Every application ends up inventing a small framing and correlation protocol on top. Or it gives up, opens one bidirectional stream, and treats QUIC as a nicer TCP.
Even the QUIC working group ran into a version of this. During the HTTP/3 work, streams could arrive before the information saying where they fit in the priority tree. The fix they agreed on was a “holding pen” for streams that show up early.
The API exposes all that is possible. It doesn’t expose the shape most exchanges actually have.
A socket that yields bytes, and sometimes new sockets
So my suggestion for the API we hand to casual developers:
A socket that yields bytes, and optionally new sub-sockets.
You start with one stream. It behaves like the stream everyone already knows how to program: read bytes, write bytes, in order. That is where the setup conversation happens.
At any point either side can fork a child stream from inside that stream. The child shows up on the other side at that position in the parent’s byte sequence. So the reader knows exactly which part of the conversation it belongs to.
root stream:
-> "I want to sync project X"
<- "I have these 3 things you're missing"
<- [child stream: blob A]
<- [child stream: blob B]
<- [child stream: blob C]
<- "that's all"
After that the children run independently. Loss on blob A doesn’t stall blob B. Each child is itself a stream, so it can fork again if it has structure of its own.
That gives you:
- a serial phase that looks exactly like TCP;
- a parallel phase that gets QUIC’s independence;
- no separate correlation scheme, because the parent-child relationship is the correlation;
- a program that reads top to bottom like the conversation it implements.
A tree of streams instead of a bag of them. Roughly the model of a process that forks: do the shared setup once, then split.
This doesn’t need a new transport
This is an API claim, not a wire protocol claim.
You can build “a stream that yields sub-streams” on top of QUIC streams fairly directly. The fork point in the parent is a small marker; the child is a new QUIC stream whose first bytes say which parent and position it belongs to. If the child arrives before the parent reaches the fork marker, it waits in the library until it does.
That waiting needs care. QUIC has per-stream and per-connection flow control, and RFC 9308 warns that interdependent streams can deadlock if the receiver leaves one unread while waiting on another. Which is exactly why it belongs in the library: bound what an early child may buffer, don’t let children starve the parent they depend on. Solve it once, instead of every application getting it slightly wrong.
You could build it on TCP too. You’d lose the head-of-line independence, but the application code wouldn’t change. The program describes its dependency structure; the transport exploits as much of it as it can.
Back to Amdahl. The formula only helps when you know which part is serial. A bag of streams hides that from the library. A tree of streams says it out loud.
This is not a new idea
After writing the above I went looking, and Bryan Ford proposed almost exactly this in 2007: Structured Stream Transport. SST extends the stream abstraction with “a hierarchical hereditary structure, allowing applications to create lightweight child streams from any existing stream.” Children are created from a parent, accepted by the peer from that parent, get their own flow control, and don’t block each other. The paper even treats datagrams as short-lived child streams, which unifies both ends of the spectrum this post started with.
QUIC ended up with a similar core: many lightweight streams sharing one congestion controller. What it doesn’t have is the hereditary part. QUIC streams are a flat set, identified by number, with no parent.
So my claim narrows to: we built the transport that can carry structured streams, then shipped it with the flat API. The tree still has to be rebuilt in every application, and most don’t bother.
One difference from SST, as far as I can tell from the paper: there a child is accepted from its parent, but not at a position in the parent’s bytes. I think the position matters for casual use. “Read the header, then the next thing out is the blob it described” is the program people already write against TCP.
What I’m not claiming
The full QUIC API should still exist. Some applications really want independently opened streams, unreliable datagrams, fine-grained priorities, or peers opening streams without a parent. Game state and media are the obvious cases.
The claim is that the default API, the one in the first example of the docs, should be a stream that can fork. It matches how most exchanges are structured, it is easy to start with, and it degrades into ordinary stream programming when you don’t need the parallel part.
Start with a stream. Let it split when the conversation says it can.
First written: 2026-09-24
Last edited: 2026-09-24