DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Designing gRPC APIs for Mobile: Deadline Propagation, Channel Pooling, and the Retry Budget That Prevents Thundering Herds

---
title: "gRPC for Mobile: Deadlines, Channel Pools, and the Retry Budget That Stops Thundering Herds"
published: true
description: "Master gRPC for mobile clients: propagate deadlines through your service mesh, safely pool channels on Android and iOS, and cap retry traffic before a thundering herd takes down your backend."
tags: android, ios, mobile, api
canonical_url: https://mvpfactory.co/blog/grpc-mobile-deadlines-channel-pools-retry-budgets
---
Enter fullscreen mode Exit fullscreen mode

What you will build

By the end of this guide, you will have a production-grade gRPC configuration for your mobile client: deadlines that cascade correctly through your service mesh, a channel pool that does not leak on Android or iOS, and a retry strategy that stays well-behaved when your network degrades. This is the exact config that would have prevented an 18-minute backend outage — which we will walk through to make the lessons concrete.

Prerequisites

  • Familiarity with gRPC concepts (channels, stubs, service configs)
  • An Android project using grpc-kotlin or an iOS project using grpc-swift
  • A backend with at least two service layers (API gateway + downstream service)

The incident that changed how I think about gRPC

At 11pm on a Tuesday, a stadium Wi-Fi handoff triggered 400,000 simultaneous retries. The backend was down for 18 minutes. The root cause was three missing lines of config.

Most teams treat gRPC like a slightly faster REST call. It is not. The deadline, retry, and connection models are fundamentally different — and each interacts with the others in ways that only surface under load. Here is the minimal setup to get this right.


Step 1 — Deadline propagation: cascade or die

Here is the gotcha that will save you hours: a deadline is not a timeout. A timeout fires client-side and abandons the call. A deadline propagates through every hop of your service mesh via the grpc-timeout header, letting upstream services abandon work that will never reach the client anyway.

Set deadlines on the stub, not the channel:

// Android — set a 2.5s deadline on the stub, not the channel
val response = stub
    .withDeadlineAfter(2500, TimeUnit.MILLISECONDS)
    .getHealthData(request)
Enter fullscreen mode Exit fullscreen mode

Your server deadline must always be shorter than your client deadline. Here is a cascade pattern I use in every project:

Layer Deadline Why
Mobile client 3000ms User-facing tolerance
API gateway 2700ms Buffer for network RTT
Backend service 2400ms Buffer for internal hops
Database query 1800ms Leave room for retries

A 300ms cascade buffer at each hop is conservative. On low-latency internal networks, 150ms is usually enough.


Step 2 — Channel pooling: size it or leak it

gRPC multiplexes requests over a single HTTP/2 connection per channel. One channel per backend endpoint is usually correct on mobile. Before you reach for a pool, understand the cost: each channel holds a TCP connection, negotiates TLS, and maintains a flow control window. On Android, an unclosed channel holds a wake lock. On iOS, it fights the system's network interface scheduler.

A practical ceiling is 2–4 channels per host. Here is the iOS setup:

// iOS — pool of 2 channels for the primary API host
let pool = GRPCChannelPool.with(
    target: .host("api.example.com", port: 443),
    transportSecurity: .tls(GRPCTLSConfiguration.makeClientDefault()),
    eventLoopGroup: PlatformSupport.makeEventLoopGroup(loopCount: 2)
)
Enter fullscreen mode Exit fullscreen mode

Two channels handle roughly 200 concurrent streams comfortably. Going beyond 4 per host rarely improves throughput — and it meaningfully increases your server-side connection count, which bites hard when running behind a service mesh with per-replica connection limits. Always close channels in onDestroy / deinit.


Step 3 — The retry budget: two mechanisms, not one

This is where most implementations break down. The docs do not make this clear, but there are two distinct concepts here, and conflating them is exactly how you end up with an 18-minute outage.

The client-side retryPolicy controls per-call behavior:

// gRPC service config — per-call retry behavior, client-side only
val serviceConfig = """{
  "retryPolicy": {
    "maxAttempts": 3,
    "initialBackoff": "0.5s",
    "maxBackoff": "10s",
    "backoffMultiplier": 2,
    "retryableStatusCodes": ["UNAVAILABLE"]
  }
}"""
Enter fullscreen mode Exit fullscreen mode

This limits retries per call. It does not limit total retry traffic across your fleet. If 400,000 clients each retry 3 times simultaneously, you have multiplied your load by 3x at the worst possible moment.

Fleet-wide enforcement requires server-side controls. The most practical approach: track the ratio of retries (identifiable via the grpc-previous-rpc-attempts header) to total requests at your gateway, and shed excess retry traffic above 10% of total RPC volume. With this budget enforced at the gateway, your effective traffic multiplier under a partial outage is capped at 1.1x regardless of fleet size.

Exponential backoff with jitter is necessary. It is not sufficient.


Gotchas

  • Deadline on the channel vs. the stub. Channel-level deadlines apply to every call for the lifetime of the channel. Stub-level deadlines are per-call. Always use the stub.
  • Missing grpc-previous-rpc-attempts at the gateway. Without this header check, your gateway cannot distinguish organic traffic from a retry storm.
  • Pool size creep. Teams see head-of-line blocking and double their pool size. Measure server-side connection count first — you are almost always hitting a concurrency limit, not a connection limit.
  • iOS deinit timing. Swift ARC does not guarantee deinit timing under memory pressure. Explicitly close pool channels in viewDidDisappear or your scene lifecycle, not only in deinit.

Conclusion

Three things prevent the outage:

  1. Deadlines on the stub, cascaded 10–15% tighter at each service layer so upstream services abandon work before the client times out.
  2. Channel pool capped at 2–4 per host, always closed in onDestroy / deinit.
  3. Client-side retryPolicy combined with a gateway-level retry budget — the config handles per-call behavior, the gateway enforces the fleet-wide ceiling.

If you are building a mobile app that communicates with users throughout the day — something like HealthyDesk, which sends break reminders and guided desk exercises to developers on a schedule — this pattern is exactly what keeps your notification and sync services standing when a large cohort of users reconnects simultaneously.

Relevant docs: gRPC retry design proposal (A6) · grpc-swift · grpc-kotlin

Top comments (0)