NashTech Blog

Part 1 of gRPC Under Pressure – what happens when transport flow control and application capacity tell different stories.

Table of Contents

Part 1 of gRPC Under Pressure – what happens when transport flow control and application capacity tell different stories.

The stream looked healthy. The backlog wasn’t.

Imagine a streaming ingestion service that accepts telemetry from edge devices. Each incoming event must eventually be written to a database or handed to another service. Most days, that downstream operation is fast enough. Then the dependency slows down. The gRPC stream stays connected, client sends keep succeeding, and a dashboard still shows impressive intake throughput. Meanwhile, the server retains more and more events it has not actually processed.

At first this might look like success: the API isn’t refusing work. In practice, the backlog consumes memory, makes processing latency less predictable, and leaves a difficult question at shutdown: how long will it take to finish everything already accepted?

That is an illustrative scenario, not a production incident from this research. We built a controlled Rust/Go experiment to isolate precisely this failure mode, without pretending to reproduce a real database outage.

The puzzle is that gRPC already runs over HTTP/2, which has flow control. Shouldn’t that prevent a fast sender from overwhelming a slow receiver? HTTP/2 lets the receiver regulate how many DATA bytes the sender may transfer. Once those bytes move into decoded messages owned by the application, however, transport credit no longer represents how much unfinished work the worker can handle.

Our question was simple: If HTTP/2 provides backpressure, why can a gRPC handler still accumulate an ever-growing application queue?

The public research repository contains the Rust and Go implementation, experimental protocols, raw measurements, analysis, and reproduction instructions. We will link to the two experiment reports when we introduce those rounds below.

What you’ll learn

  • What HTTP/2 flow control protects – and where its responsibility ends.
  • How messages can move from transport buffers into application-owned memory.
  • Why an unbounded channel can hide overload behind successful sends.
  • Why reserve queue capacity before receiving is the critical ordering choice.
  • What the longer experiment taught us about deadlines, cancellation, and unfinished work.

The small streaming service we built to investigate this

Before getting into HTTP/2 windows and backpressure, here’s the system we’re investigating.

Imagine a telemetry service receiving events from another application. The sender produces events continuously, but the server needs time to process each one. What happens when events arrive faster than the server can finish them?

We built a deliberately small system to answer that question, using two programs:

  • A Go load generator (the sender): Opens a gRPC client-streaming RPC and sends messages containing 16 KiB of data. We can control how quickly it attempts to send them. For the main experiment, the target is 300 messages per second.
  • A Rust gRPC server (the receiver): Built with Tokio and tonic. It receives those messages and simulates processing by waiting 10 milliseconds per message. A single worker completes approximately 90 messages per second in our measured environment.

The server is intentionally simple. There’s no database, external API, or complicated business logic. That lets us focus on one question: What happens between receiving a message and finishing its processing?

One message’s journey

Here’s what happens when the Go program sends an event:

  1. The Go program calls gRPC’s Send method.
  2. grpc-go serializes the protobuf message and passes its bytes through HTTP/2.
  3. The Rust server’s HTTP/2 stack receives those bytes.
  4. tonic decodes the protobuf message and delivers it to our application handler.
  5. Depending on the implementation, the handler either processes the message directly or places it into a Tokio channel.
  6. A worker takes messages from that channel and simulates processing them.

A Tokio channel is an in-memory queue used to pass messages between asynchronous tasks. It is part of our Rust application, not a feature of HTTP/2.

That distinction matters because receiving a message and finishing its processing are two different events.

Three ways to implement the same service

We built three versions of the server’s receive behavior. They use the same message size and simulated processing delay.

VersionWhat the server doesWhy we test it
Direct processingReceive one message, process it, then receive the nextEstablish a baseline without an application queue
Unbounded queueReceive a message, immediately enqueue it, then receive anotherSee what happens when receiving can outrun processing
Bounded queueWait for an available queue slot before receiving another messageSee whether limiting application intake changes the outcome

For the bounded version, we allow 32 waiting messages. The worker is not faster in this version. The difference is that the handler cannot keep accepting decoded messages into that queue when all its slots are occupied.

For the unbounded version, the channel has no built-in capacity limit. Our experiment still includes a separate safety limit to prevent uncontrolled memory consumption.

Why this setup is interesting

The sender is configured to offer roughly 300 messages every second. The worker can complete only about 90. That leaves a potential mismatch of around 210 messages per second.

-> Where do those extra messages go?
Do they remain in the Go client?
Does HTTP/2 stop the sender?
Does the Rust server buffer them?
Or can they accumulate in our application queue even though gRPC already has flow control?

To answer that, we first need to understand what gRPC’s built-in flow control actually controls.

How gRPC’s built-in flow control actually works

Why does a streaming protocol need flow control in the first place? A sender might generate data much faster than a receiver can read it. Without a limit, the sender could keep filling transport buffers while the receiver falls further behind. gRPC uses HTTP/2’s credit-based flow control to constrain that transfer. It applies to streaming traffic, and the mechanism works in either direction. The official gRPC flow-control guide describes this distinction between writing to the framework and getting data onto the network.

A window is permission to send bytes, not permission to finish work

Imagine the server tells the Go client: “You may send up to 64 KiB of DATA before I grant more credit.” That credit is the flow-control window, the sender’s current allowance for transferring DATA bytes – not a count of worker slots or completed requests. Each HTTP/2 stream has its own window, and the entire connection has a second window shared by its streams. Sending DATA consumes credit from both. The sender can transmit only while both have enough available credit. This is a directional rule: the server advertises credit for client-to-server DATA; the client independently controls server-to-client DATA.

For precision, HTTP/2’s default initial window is 65,535 bytes, not exactly 64 KiB (65,536 bytes). The following example rounds to 64 KiB solely to make the arithmetic readable. Our experiment explicitly configured 65,535-byte server stream and connection windows. See RFC 9113 section 5.2 and section 6.9.

Follow four chunks through the window

  1. Start: for illustration, both the stream and connection have 64 KiB available. Assume this is the only active stream.
  2. First 16 KiB of DATA: both windows decrease to 48 KiB. After a second chunk, they have 32 KiB; after a third, 16 KiB.
  3. Fourth 16 KiB: both windows reach zero. If either applicable window has insufficient credit for more DATA, the sender cannot transmit that DATA until credit becomes available. It can buffer pending data locally or wait; other HTTP/2 control frames are not subject to DATA flow control.
  4. Receiver frees capacity: the server’s HTTP/2 implementation can issue WINDOW_UPDATE for the stream and for the connection, each with a byte increment. Once the sender receives sufficient updates, it may send more DATA.

This is a credit-accounting illustration, not a packet trace. Real gRPC messages have a length-prefixed envelope and protobuf encoding; one message is not guaranteed to equal one DATA frame. Frame boundaries and sizes can vary, windows may be replenished before reaching zero, and implementations can batch or coalesce credit updates. A WINDOW_UPDATE is not emitted once per message, and it is not an acknowledgment that business processing succeeded. RFC 9113 specifies the credit rules, not the receiver’s precise update schedule.

When does the receiver give that credit back?

Here is the distinction that matters for our experiment: releasing transport receive capacity does not mean finishing the work carried by those bytes.

Return to the simplified 64 KiB example. After four 16 KiB DATA chunks, the sender has no remaining credit in either window. Suppose the receiver consumes 32 KiB from its HTTP/2 receive buffers. It can account for that freed capacity and advertise more credit with WINDOW_UPDATE on the stream and connection. When the sender receives sufficient updates, it can resume DATA transmission. These are independent windows; credit at one level does not bypass a shortage at the other.

The receiver has not necessarily processed two business messages. HTTP/2 DATA frames and protobuf messages are different units: one DATA frame can contain part of a protobuf message or bytes from multiple messages. Transport credit is counted in DATA flow-controlled octets, including any DATA padding, rather than in application message completions. The nine-byte HTTP/2 frame header is not part of the flow-controlled DATA length. RFC 9113 defines this accounting, but not the exact time at which a particular implementation sends its updates.

In our audited stack, tonic Streaming::message() decodes buffered bytes and polls additional body frames when needed. Underneath tonic, Hyper 1.12.0 calls h2.flow_control().release_capacity(bytes.len()) after h2 returns a DATA buffer and before Hyper hands that frame upward. h2 0.4.20 then tracks the released capacity and can emit thresholded or coalesced WINDOW_UPDATE frames. Neither operation waits for the worker’s processed count to increase. The pinned implementation source audit identifies the relevant source paths and versions.

Three events therefore occur on different schedules: (1) the receiver releases local h2 capacity, (2) the sender eventually receives a WINDOW_UPDATE and can use the advertised credit, and (3) the application worker completes a decoded message. We measured application queue behavior, not the individual timing between these transport events. A capacity release is not necessarily an immediate WINDOW_UPDATE, and there is no one-update-per-message rule.

Figure 1 – Conceptual illustration of stream and connection DATA credit and later WINDOW_UPDATE replenishment. All 64 KiB/16 KiB values are teaching simplifications, not packet measurements.

What makes the Go sender slow down?

If the server stops consuming from its receive path, available receive capacity can eventually stop being replenished. The client-side HTTP/2 sender then runs out of permission to put more DATA on the connection. Pending bytes may remain in grpc-go or OS buffers, and backpressure can eventually affect application writes. The exact point at which a Go Send call waits depends on grpc-go buffering and scheduling, not just the window size. Window exhaustion can block further DATA transmission without immediately blocking an application-level Send call.

That is why a successful Send means the client API accepted the message into its sending path, not that the server received the bytes, decoded the message, or completed its work. The gRPC documentation explicitly warns that a stream write need not have gone onto the network when the call returns. A response summary or application acknowledgment has a different meaning from transport credit.

Where does that protection stop in our Rust service?

Now follow one message through the real stack: Go producer → grpc-go → HTTP/2 DATA → server h2 receive path → tonic protobuf decoding → application handler → Tokio MPSC queue → worker. The first stages deal with transport bytes and decoded message delivery. The last stages deal with ownership of unfinished application work.

Figure 2 – Conceptual ownership boundary: Hyper/h2 receive-credit accounting can advance before worker completion. Not a measured frame sequence.

Our pinned stack uses tonic 0.14.2, Hyper 1.12.0, and h2 0.4.20. The dependency source audit examines how its receive path manages capacity. In this implementation, Hyper releases receive capacity after obtaining DATA from h2, before passing the DATA frame upward for tonic to decode. That action can precede completion of even one protobuf message, let alone the worker’s business processing. h2 subsequently handles receive-window bookkeeping and possible WINDOW_UPDATE emission. The peer receiving that update and a worker completing work are still separate events; we did not measure their precise timings.

Once stream.message().await yields a decoded Payload, the handler owns it. If the handler promptly puts it into an unbounded Tokio queue, it can ask tonic for the next message even while the worker is behind. That continued receiving can allow the transport to keep making progress. HTTP/2 is doing its job at the byte boundary; the application has moved the backlog beyond that boundary.

If the handler instead reserves bounded queue capacity before calling stream.message().await, a full channel prevents this handler from calling stream.message().await again until a permit becomes available. It does not immediately empty or freeze bytes already buffered in tonic, Hyper, h2, sockets, or grpc-go. The pause can eventually propagate upstream as receive credit ceases to be replenished. It need not block the very next Go Send, because buffering still exists between these stages. This is end-to-end backpressure through the chosen application handoff, not a promise of bounded total process RSS.

Messages can still occupy grpc-go buffers, sockets, HTTP/2 buffers, protobuf decoding, the handler, and the worker. The bounded channel controls only its own 32 slots. The experiment tests whether that constraint materially changes intake and queue growth; it does not isolate an individual HTTP/2 stall or capture a one-to-one mapping between DATA and WINDOW_UPDATE.

Three ways to handle the same stream

To make the boundary visible, we used one Go client-streaming RPC and one Rust tonic worker. The Go client offered 300 messages/s, each carrying 16,384 bytes (16 KiB) of deterministic payload. The worker deliberately waited 10 ms per message. This is a controlled stand-in for a slow downstream dependency, not a CPU saturation benchmark; timer overhead meant actual worker throughput was roughly 89–90 messages/s, not the theoretical 100.

The controlled comparison centers on the handler’s policy for moving incoming messages toward the worker:

  1. Direct: receive a message, finish processing it, then receive the next. No application queue.
  2. Unbounded: receive a message, enqueue it into a Tokio unbounded MPSC channel, and immediately receive again. The worker drains the queue independently.
  3. Bounded K=32: reserve space in a 32-message Tokio channel before receiving the next message. When all permits are occupied, the handler stops pulling new messages.
Figure 3 – Conceptual queue policies under overload. Bounded admission constrains application intake; it does not make the worker faster or bound all process memory.

The hypothesis was that unbounded intake would run near the offered rate while the worker stayed near its own capacity, leaving the difference in the queue. Direct and bounded handling should instead push that mismatch upstream, lowering completed client sends toward worker throughput.

To test it, we measured queue depth and owned payload, server RSS (resident process memory), completed Go sends, worker completions, Send latency, and RPC completion/cancellation. RSS tells us how much physical memory the process occupies; it does not tell us which exact allocations own every byte.

The short experiment raised the question. The long one answered more.

We labeled the two rounds of this investigation E1 and E1B. These are our own experiment names, not gRPC or HTTP/2 terminology. E1 (Experiment 1) was the initial 12-second baseline, documented in the E1 baseline report (commit 3374f12). E1B (Experiment 1, extension B) was the 60-second follow-up investigating sustained backlog growth and the drain lifecycle, documented in the E1B validation report (commit 25c3dc0).

Our initial E1 experiment used 12-second offered-load intervals with a Rust debug build. Across five valid runs per variant, it already showed the split: the unbounded queue grew around 209.82 messages/s, while bounded K=32 reached a maximum queue depth of 32 and completed sends around 89.49/s. The direct variant also tracked worker capacity. All included E1 RPCs drained within the deadline.

That was enough to identify a mechanism, but not enough to understand what a longer backlog would do to the request lifecycle. We ran E1B, using 60-second offered-load intervals, a Rust release build, the hardened server source, and additional telemetry. Each variant had five fresh-process repeats. We kept the same offered rate, payload, single worker, K=32, and 120-second whole-RPC deadline.

E1 and E1B are not a clean one-variable comparison: duration, build mode, server source, and telemetry changed. We use E1B as the primary evidence and E1 as the earlier observation, not as proof of the performance effect of any one of those changes.

Before the measurements: how fast should the queue grow?

A little queueing arithmetic makes the experiment easier to predict. Let λ be messages entering the application queue per second, μ be worker completions per second, and Q be the backlog. Under sustained overload, while those rates are approximately stable:

dQ/dt ≈ λ − μ

With an offered rate near 300 messages/s and a measured worker capacity near 90 messages/s, an unbounded receive-first queue could grow by roughly 210 messages/s, provided the handler keeps admitting input near the offered rate. This is a prediction from approximate rates, not a new measurement. It is also not the same as equating each successful Go Send with a decoded or processed message.

For the bounded K=32 variant, reaching capacity changes how quickly the handler can receive and enqueue new work; it does not increase the worker’s service rate. That should shift pressure back toward grpc-go instead of permitting the application queue to keep growing. Little’s Law is not the right shortcut for this deliberately growing queue: its ordinary steady-state interpretation is not satisfied during sustained overload.

Discovery 1: the unbounded queue absorbed the rate mismatch

Figure 4 – Recreated from the published E1B median queue-growth rates, not an original time-series plot. Five runs per variant; the unbounded median was 208.881 messages/s.

The predicted ~210 messages/s was close to E1B’s observed median of 208.881 messages/s. During E1B, the unbounded Go client completed about 298.86 Sends/s, but its worker completed only 90.08 messages/s. The difference showed up where we expected: a fitted queue-growth rate of 208.881 messages/s (observed range 207.299–209.208). The positive growth persisted in both early 5–20 s and late 40–55 s windows; the main fitted window was 5–55 s.

Figure 5 – Recreated from E1B median rates. Completed Go Sends are not worker completions or server-side processing acknowledgments.

Direct handling completed 89.62 Sends/s, and bounded K=32 completed 89.82 Sends/s. Both tracked their worker rates, also 89.62/s and 89.82/s respectively. The unbounded version appeared much faster at intake precisely because it was moving unfinished work into memory.

In the unbounded runs, the maximum queue reached a median 12,505 messages (range 12,433–12,520) and 195.39 MiB of owned payload (range 194.27–195.62 MiB). The bounded queue reached 32 messages in every run, equivalent to 0.50 MiB of queued payload. The direct variant had no application queue.

Discovery 2: memory grew alongside queued payload

Figure 6 – Recreated from E1B median fitted slopes: queued payload 3.264 MiB/s and server RSS 3.275 MiB/s. Similar slopes do not establish exact heap attribution.

The unbounded queue’s owned payload grew at 3.264 MiB/s (range 3.239–3.269). The server’s RSS grew at 3.275 MiB/s (range 3.252–3.279). Peak server RSS during offered load reached a median 199.59 MiB, versus 3.73 MiB for direct and 4.69 MiB for bounded.

The similar slopes are consistent with retained decoded payloads contributing substantially to RSS growth in this setup. They are not a heap profile: RSS also reflects allocator behavior, stacks, runtime structures, and other resident pages. We did not attribute individual resident pages to queued objects.

The important engineering contrast is not a particular MiB threshold. It is that unbounded queue depth kept increasing throughout the observed load, while the bounded queue had an enforced capacity. Sampling every 200 ms corroborated that behavior, but the channel implementation – not the sampling interval – is what enforces the 32-item limit.

The small code-ordering decision that made pressure travel upstream

Here is the bounded branch from the measured Rust server revision. Elisions are marked; this is not invented pseudocode:

Verified excerpt from the measured Rust server source (9773403). Comments marked “omitted” replace source lines; all other lines are copied from the source in their original order.

let (tx, rx) = mpsc::channel(self.capacity);
cleanup.task = Some(tokio::spawn(worker(
    Rx::B(rx),
    self.state.clone(),
    self.service_ms,
)));
loop {
    let start = Instant::now();
    let permit = tx
        .reserve()
        .await
        .map_err(|_| Status::internal("worker closed"))?;
    self.state.counts.lock().unwrap().wait_ns += start.elapsed().as_nanos() as u64;
    let Some(p) = stream.message().await? else {
        drop(permit);
        break;
    };
    // ... omitted: exact queue ownership/accounting statements ...
    permit.send(p);
    self.state.notify.notify_one();
}
// ... omitted: close sender and notify worker ...

Notice what happens before stream.message().await: the handler awaits tx.reserve(). If the worker is slow and all 32 slots are occupied, this handler cannot call stream.message().await for another application message until capacity is available. When a permit is available, it receives one message and commits that reserved capacity with permit.send(p). If the stream ends, dropping the permit releases the slot.

Compare the unbounded receive-first branch:

Verified excerpt from the measured Rust server source (9773403). Comments marked “omitted” replace source lines; all other lines are copied from the source in their original order.

let (tx, rx) = mpsc::unbounded_channel();
cleanup.task = Some(tokio::spawn(worker(
    Rx::U(rx),
    self.state.clone(),
    self.service_ms,
)));
while let Some(p) = stream.message().await? {
    let mut c = self.state.counts.lock().unwrap();
    c.received += 1;
    c.handler_bytes = p.data.len() as u64;
    if c.queue_bytes + c.handler_bytes > self.queue_limit {
        return Err(Status::resource_exhausted("queue payload safety limit"));
    }
    let bytes = c.handler_bytes;
    tx.send(p).map_err(|_| Status::internal("worker closed"))?;
    c.enqueued += 1;
    c.queued += 1;
    c.queue_bytes += bytes;
    c.handler_bytes = 0;
    self.state.notify.notify_one();
}
// ... omitted: close sender and notify worker ...

There is no capacity permit in this path. tx.send(p) transfers ownership into the unbounded queue without waiting for a free slot. Here unbounded means there is no capacity-based channel backpressure, not infinite permitted memory. The queue_limit check is an experiment safety abort at 256 MiB of queued payload, not a normal wait-for-capacity mechanism.

Why does ordering matter? If we receive first and then wait for queue space, we have already admitted one more decoded message into the handler. Reserving first aligns the receive rate with downstream queue capacity. When the queue fills, the handler stops pulling messages from tonic; pressure can then propagate through the gRPC receive path and transport toward the Go sender. In our measured workload, that showed up as completed Sends falling toward the worker’s rate.

This is an application-level capacity guarantee, not a promise that HTTP/2 immediately blocks a particular Send call, nor a bound on all process memory. Transport buffers, sockets, protobuf objects, the worker’s in-flight message, and allocator overhead remain outside the 32 queued items. We did not isolate the exact duration of any wire-level flow-control stall.

The surprise was at the end of the stream

After the 60-second offered interval, the Go client stopped sending and tried to finish the RPC. That is when the unbounded queue’s apparent throughput advantage became a lifecycle problem.

At about 300 messages/s for 60 seconds, the sender could hand off roughly 18,000 messages. At roughly 90 worker completions/s, processing that many messages would require about 200 seconds in total, including the work completed during the 60-second sending interval, ignoring additional overhead. But the experiment’s 120-second deadline covered the whole RPC, including the offered interval and subsequent drain. A large backlog therefore had insufficient time to finish under that deadline.

Figure 7 – Recreated from E1B outcomes: all five unbounded RPCs hit the whole-RPC deadline during drain; all ten direct/bounded RPCs drained.

All five unbounded E1B RPCs ended with DeadlineExceeded during drain. Cleanup explicitly discarded 7,069–7,148 messages per run. One representative run recorded 17,851 completed Sends, 10,782 processed at cleanup, and 7,069 discarded. Those numbers are not interchangeable: a completed Send is not a completed unit of application work.

All five direct and all five bounded RPCs drained successfully. Their lower intake rates had kept them close to processing capacity rather than storing an enormous obligation for later.

A queue gauge returning to zero after cancellation does not prove successful processing. In these unbounded runs, cleanup emptied the queue by discarding work. A longer deadline might have allowed more drain time, but we did not run that alternative; the measured claim is limited to this configured workload and deadline.

What I’d change in a real gRPC service

These are engineering recommendations informed by the experiment, not additional measured production findings.

  1. Put a capacity bound at each application handoff. Choose queue size using a realistic maximum payload budget, observed message-size distribution, expected bursts, and acceptable waiting time – not an arbitrary huge default. Remember that a 32-item channel caps queued items, not total RSS.
  2. Reserve capacity before reading more from the stream. In Tokio, tx.reserve().await before stream.message().await is the useful pattern when each received message must enter that channel. Audit every alternative path that could bypass the limit.
  3. Separate “received,” “queued,” “started,” “completed,” and “discarded.” Do not use successful client Sends or an empty queue after cancellation as proof that business work finished.
  4. Make overload visible before it becomes an outage. Track queue depth and owned bytes, queue wait time, worker completion rate, in-flight work, RSS, and RPC outcomes. Client Send latency is a useful symptom but not a transport-only measurement.
  5. Plan cancellation and draining explicitly. Decide what happens to queued work on client cancellation, server shutdown, and deadline expiry. Define shutdown drain budgets and cancellation/discard accounting explicitly. If work must survive a process restart, an in-memory channel is not a durable queue.
  6. Treat bounds as part of admission policy, not the whole policy. Waiting forever behind a full queue may still violate deadlines. Real services need decisions about when to wait, reject, shed load, or ask clients to retry safely.

The same intake-versus-processing mismatch appears elsewhere, but the controls differ: REST handlers need request/concurrency limits; WebSocket readers need capacity-aware reads; Kafka consumers coordinate fetching, pausing, and offset commits; RabbitMQ consumers use acknowledgments and prefetch; asynchronous worker pools need bounded submission queues. None of these acknowledgments or controls has the same semantics as HTTP/2 WINDOW_UPDATE.

For this experiment we also used resource guards: 256 MiB queued-payload limit for the unbounded variant, 512 MiB sampled server RSS limit, and the 120-second whole-RPC deadline, plus additional E1B client/process/output safeguards. Those were research safety controls; they should not be mistaken for a production overload design.

What the evidence does – and doesn’t – say

The core finding is well supported for this pinned, single-host workload: HTTP/2 flow control can operate while an application continues moving decoded messages into an unbounded queue. In E1B, queued payload and RSS rose at similar sustained rates; bounded reserve-before-receive held the application queue to 32 messages and reduced sender throughput toward worker capacity. The longer unbounded runs also failed to drain before their configured deadlines.

There are important limits. We used one localhost connection, one stream, one sleeping worker, and five repeats per variant. The observed min–max ranges are descriptive, not confidence intervals. We sampled RSS and counters periodically, did not run a heap profiler or independent-machine replication, and did not trace every buffer in grpc-go, tonic, h2, or the operating system. E1B did not add a packet capture.

The separate E1 h2 DEBUG diagnostics recorded 2,334 received DATA-frame events and 19,117,606 bytes of logged stream receive credit for unbounded, versus 785 events and 6,377,914 bytes for bounded during roughly the first four seconds after handshake. Those are stack log observations, not on-wire packet timing, message counts, or a measurement of transport-only stall duration. The source audit explains how receive capacity can be released before application work finishes, and how h2 can coalesce WINDOW_UPDATE events. We do not claim HTTP/2 flow control was broken.

Source code and experimental evidence

Protocol references

Everything needed to inspect the experiments is in the gRPC Under Pressure repository. These links pin the published reports and measured implementation where possible:

Experiment protocols and reports

Implementation and measurements

Reproduction

Start with the experiment setup and reproducibility guide, then follow the exact E1 or E1B commands in their reports. Both variants use resource safety guards; do not run the unbounded case without them or equivalent limits.

For context, the measured setup used Linux x86_64, Rust 1.99.1, Go 1.27.3, protoc 31.1, Tokio 1.48.0, tonic 0.14.2, grpc-go 1.76.0, two Tokio worker threads, GOMAXPROCS=2, fixed 65,535-byte HTTP/2 stream/connection windows, no compression, and one client-streaming RPC per fresh process. The source audit covers resolved Hyper 1.12.0 and h2 0.4.20 behavior. E1 used 12-second debug runs; E1B used 60-second release runs. One E1 direct run was excluded and replaced for source-provenance ambiguity, not for its result; E1B retained every deadline-aborted unbounded run. The full setup and execution commands are documented in the reports. Do not run the unbounded variant without the published resource guards or an equivalent budget.

For the original experiment figures rather than the simplified editorial charts in this article, inspect the E1 comparison, E1 transport diagnostics, E1B comparison, E1B telemetry, and E1 versus E1B comparison. The E1B report links individual run manifests. A separate independent PUBLISH verdict was supplied outside the checked-in files, so no repository link to it is implied.

Backpressure needs an application boundary

A useful streaming-system design-review question is: What event releases the capacity for accepting more work? In HTTP/2, it is receive capacity for DATA bytes. In a bounded Tokio queue, it is an available message slot. At the worker, it is completed application processing. Those events are not interchangeable, and a successful Go Send is not an acknowledgment that a business operation finished.

In our experiment, an unbounded channel let decoded messages accumulate at approximately the difference between intake and processing rates, while reserve-before-receive linked further application intake to a finite queue. HTTP/2 was protecting its own byte-transfer boundary; the application still needed a policy for unfinished work.

The next challenge is deciding when to accept, wait, reject, or shed load as service capacity changes. That is the subject of Part 2: Building a Self-Protecting gRPC Server with Adaptive Concurrency.

gRPC Under Pressure

A three-part engineering series on backpressure, admission control, and overload.

  1. Part 1 of gRPC Under Pressure – what happens when transport flow control and application capacity tell different stories.ct, Retry, or Drop?
  2. Part 2 We Built Adaptive Backpressure for gRPC. Was a Fixed Queue Better?
  3. Part 3 Backpressure Is Not an Overload Policy: Wait, Reject, Retry, or Drop?

Picture of Hoang Vo Huy

Hoang Vo Huy

Suggested Article

Scroll to Top