How a gRPC Server's Ping Throttling Policy Let an AWS NLB Silently Kill Connections for 40 Minutes
← Back
November 10, 2026Architecture9 min read

How a gRPC Server's Ping Throttling Policy Let an AWS NLB Silently Kill Connections for 40 Minutes

Published November 10, 20269 min read

03:14 UTC. PagerDuty for checkout-svc won't stop. p99 latency on tax calculation calls is at 38 seconds, up from a baseline of 90 milliseconds. Error rate is flat at zero. Nothing is failing. Everything is just... waiting.


the setup

tax-svc runs on a fleet of EC2 instances in an Auto Scaling Group, fronted by an internal AWS Network Load Balancer instead of an ALB, because it's pure gRPC and the team wanted L4 passthrough with no HTTP/2-to-HTTP/1.1 translation in the middle. checkout-svc opens one long-lived gRPC channel to the NLB's DNS name at startup and reuses it for every tax calculation call, roughly 1,800 RPCs a minute at steady state.

Both sides configured gRPC keepalive, which looked like the responsible thing to do. The client pings every 20 seconds to keep the connection warm:

checkout-svc/tax_client.go
conn, err := grpc.Dial(
  "tax-svc-internal-nlb.example.internal:50051",
  grpc.WithKeepaliveParams(keepalive.ClientParameters{
    Time:                20 * time.Second,
    Timeout:              5 * time.Second,
    PermitWithoutStream: true,
  }),
)

Nobody checked what the server side allowed.


the scramble

First theory: tax-svc is overloaded. CPU, memory, and goroutine counts on every instance are flat and boring. The Auto Scaling Group has three of three healthy targets. Application-level request logs on tax-svc show call volume exactly matching the baseline, no backlog, no slow queries against its local rate tables. Fifteen minutes spent confirming the server is doing nothing wrong, because it genuinely isn't.

Second theory: an AZ-level network event. Three other services call across the same two AZs with no symptoms at all. Pulling up the NLB's CloudWatch metrics, ActiveFlowCount is climbing steadily while NewFlowCount stays flat, which doesn't match a clean network partition. Connections are accumulating somewhere, not failing to form. That rules out the network layer being the actual break and burns another twenty minutes chasing it.


the hunt

With both obvious culprits cleared, the next step is looking at what a single stuck call actually does. A manual gRPC call against tax-svc directly, bypassing the NLB, returns in 40 milliseconds. The exact same call through the NLB, on the channel checkout-svc has been holding since before the incident started, just hangs until the client's own 5-second application deadline kills it. The server never saw the request.

That's the tell: the server isn't slow, the connection is dead, and neither side has noticed. A packet capture on a checkout-svc host, filtered to the NLB's target IP, confirms it. No RST, no FIN. From the kernel's point of view the TCP connection is still fully established. It just stopped carrying anything.

tcpdump -i eth0 host 10.4.12.9 and port 50051
03:09:41 IP checkout-svc.44822 > 10.4.12.9.50051: Flags [P.], length 142
(nothing after this for the life of the connection)

Enabling GRPC_GO_LOG_SEVERITY_LEVEL=info GRPC_GO_LOG_VERBOSITY_LEVEL=2 on a checkout-svc instance and restarting it turns up the line that explains everything, logged ninety minutes before the alert fired:

checkout-svc stdout
transport: Got too many pings from the client, closing the connection.
http2: received GOAWAY, errcode: 11 (ENHANCE_YOUR_CALM)

the find

tax-svc's gRPC server had its own keepalive enforcement policy, added six weeks earlier to protect against a noisy client that was pinging too aggressively:

tax-svc/server.go
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
  MinTime:             5 * time.Minute,
  PermitWithoutStream: false,
}),

MinTime of five minutes means the server will tolerate a client ping no more often than once every five minutes before deciding the client is abusive and sending GOAWAY. The checkout-svc client was configured to ping every 20 seconds. The first ping inside that five-minute window is fine. The second one, 20 seconds later, gets the server's patience exhausted and a GOAWAY with ENHANCE_YOUR_CALM.

gRPC's client-side keepalive implementation responds to that specific GOAWAY by doubling its ping interval, then doubling it again the next time it gets throttled. Within a handful of cycles the effective ping interval blows past 350 seconds, the NLB's idle timeout for TCP flows. The NLB doesn't know a gRPC ping from application data, it just counts bytes on the wire. Once the real interval crossed 350 seconds, nothing crossed it often enough to reset the idle timer.

When the NLB expires an idle flow, it drops the connection state internally without sending a TCP RST or FIN to either endpoint. Both checkout-svc and tax-svc kept believing the connection was alive. Every RPC checkout-svc sent on that channel went into a TCP send buffer that would never be acknowledged, and sat there until the client's 5-second application deadline finally gave up and returned an error the caller could retry on a fresh connection, except the backed-off keepalive meant the next connection could hit the same throttling and die the same way.


the fix

Immediate mitigation: a rolling restart of checkout-svc to force every channel to re-establish from zero. Latency back to 90ms within four minutes of the first instance restarting. That buys time, it doesn't fix the policy mismatch that caused it.

The real fix is making the server's enforcement policy match a ping interval the client actually needs, with enough margin that one throttling event doesn't cascade into a death spiral of backoffs:

tax-svc/server.go, after
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
  MinTime:             30 * time.Second,
  PermitWithoutStream: true,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
  Time:    60 * time.Second,
  Timeout: 10 * time.Second,
}),

checkout-svc's client ping interval was pulled back to 45 seconds, comfortably under the server's new 30-second floor with room to spare, and comfortably under the NLB's 350-second idle timeout even if a ping gets dropped once. As a second, independent layer, the NLB's own idle timeout was raised from the 350-second default to 400 seconds, so a single missed ping cycle no longer has zero margin for error:

terraform: nlb target group attributes
resource "aws_lb_target_group" "tax_svc" {
  # ...
  connection_termination = true
  target_health_state {
    unhealthy_draining_interval = 0
  }
}

resource "aws_lb" "tax_svc_nlb" {
  # ...
  load_balancer_type = "network"
  idle_timeout       = 400
}

the aftermath

40 min From first ping throttle to a human restarting the first host
38s p99 tax call latency at the point the alert fired, versus 90ms baseline
350s AWS NLB's default idle timeout for TCP flows, now 400s for this target group
2 Other internal gRPC services found with mismatched client/server keepalive
  • A gRPC server's keepalive enforcement policy and a client's keepalive ping interval are two separate configurations that have to agree with each other, and with whatever idle timeout sits on the load balancer between them. Nothing enforces that agreement automatically.
  • When a server throttles a client's pings, the client doesn't just stop pinging as often, it backs off exponentially. A one-time mismatch can compound into an idle interval far longer than either config value in isolation would suggest.
  • An AWS NLB drops idle TCP flows silently, no RST, no FIN, no event either endpoint can act on directly. Both sides have to assume their connection pool can go stale without warning and build keepalive that genuinely keeps the connection active, not just configured to look like it does.
  • A connection that hangs instead of erroring is harder to detect than one that fails outright. Error-rate alerting stayed flat through the entire incident. Latency and open-connection-age metrics were the only signals that caught it.

Both keepalive configs were individually reasonable. The client's 20-second ping was a sane default for a service that cared about fast failure detection. The server's 5-minute enforcement policy was a sane response to a real noisy-neighbor problem from a different client months earlier. Neither side was wrong on its own. They just never agreed to be right together.

Share this
← All Posts9 min read