Handling Slow Webhook Consumers Without Starving the Dispatcher

A single customer deployed a synchronous integration that writes to a legacy ERP inside their webhook handler. Their endpoint now takes 25 seconds to return 200 OK, they send you 40 events per second, and your delivery latency for every other customer has quietly tripled. Nothing is erroring. This page is about that failure, which sits under webhook timeout and connection management and is deliberately not solvable with the tools in setting connect and read timeouts alone.

The reason it is hard is that a slow consumer is not a broken one. It satisfies every health signal you have: 0% error rate, 100% delivery success, no timeouts if your read budget is generous, no circuit breaker activity. It is consuming a shared resource — worker slots — at 125 times the rate of a well-behaved endpoint, and resource consumption is not something error-rate telemetry can see.

Prerequisites

Step 1: Separate slow-but-successful from failing

Before adding machinery, verify which failure you have, because the two need opposite treatments. Chart worker occupancy — how much wall-clock time each worker spends inside a delivery, grouped by destination — rather than request counts.

Worker occupancy over one minute Four of six workers are occupied end to end by 25-second deliveries to a single endpoint while two workers serve everyone else. Worker slot occupancy across one 60-second window w1 w2 w3 w4 w5 w6 0 s 30 s 60 s slow.example, 25 s each everyone else, 5 s each Same success rate on every bar; two thirds of the fleet serves one destination.
Occupancy, not request count, is the metric that exposes a slow consumer — every delivery above succeeded.

The query that surfaces this is share-of-occupancy per host: sum by (host) (rate(delivery_duration_seconds_sum[5m])) / sum(rate(delivery_duration_seconds_sum[5m])). Alert when any single destination exceeds a threshold you choose from tenant count — with 500 active endpoints, one taking more than 15% of total worker-seconds is an anomaly regardless of how healthy it looks.

Step 2: Give every endpoint a bounded concurrency allowance

Bulkhead isolation means each destination gets its own fixed lane of in-flight slots. Exceeding the lane does not queue inside the dispatcher; it returns immediately so the worker can pick up other work.

Per-endpoint bulkhead lanes An admission gate assigns each destination a fixed number of in-flight slots so a saturated lane cannot borrow capacity from the others. Delivery queue mixed destinations Admission gate keyed by endpoint acme.example lane: 8 slots 3 in use, admitting beta.example lane: 8 slots 5 in use, admitting slow.example lane: 4 slots 4 in use, shedding Lanes are bounded independently: a full lane cannot borrow slots from a quiet one.
The slow lane fills and stays full; because its size is fixed, the two healthy lanes never notice.

Size each lane with Little’s Law rather than by feel. To sustain R deliveries per second to an endpoint whose p95 response time is L seconds you need R × L concurrent slots. Then apply a fairness ceiling: no lane may exceed a fixed share of the worker pool, and the slowest endpoints get the smallest lanes, not the largest.

Endpoint p95 Target rate Little’s Law slots Lane granted (pool of 32) Effect
0.2 s 40 /s 8 8 Full rate sustained
1.0 s 20 /s 20 12 Mild queueing, no starvation
5.0 s 10 /s 50 6 Throttled to ~1.2 /s, backlog grows
25 s 40 /s 1000 4 Throttled to 0.16 /s, escalation triggered

The bottom row is the point. Granting 1,000 slots is impossible; granting 4 is a deliberate decision that the consumer’s backlog is their problem, not a fleet-wide outage.

import asyncio
from dataclasses import dataclass


class LaneFull(Exception):
    """Raised when an endpoint's bulkhead lane has no free slot."""


@dataclass
class Lane:
    semaphore: asyncio.Semaphore
    size: int


class Bulkhead:
    def __init__(self, default_size: int = 8, overrides: dict[str, int] | None = None):
        self._default = default_size
        self._overrides = overrides or {}
        self._lanes: dict[str, Lane] = {}

    def _lane(self, endpoint_id: str) -> Lane:
        lane = self._lanes.get(endpoint_id)
        if lane is None:
            size = self._overrides.get(endpoint_id, self._default)
            lane = Lane(semaphore=asyncio.Semaphore(size), size=size)
            self._lanes[endpoint_id] = lane
        return lane

    def in_use(self, endpoint_id: str) -> int:
        lane = self._lane(endpoint_id)
        return lane.size - lane.semaphore._value

    async def run(self, endpoint_id: str, coro_factory, *, wait: float = 0.5):
        lane = self._lane(endpoint_id)
        try:
            await asyncio.wait_for(lane.semaphore.acquire(), timeout=wait)
        except asyncio.TimeoutError:
            raise LaneFull(endpoint_id)
        try:
            return await coro_factory()
        finally:
            lane.semaphore.release()


BULKHEAD = Bulkhead(default_size=8, overrides={"ep_slow_erp": 4})

The wait=0.5 acquisition deadline is what distinguishes a bulkhead from a queue. Waiting indefinitely for a slot still holds the worker; waiting half a second and giving up hands the worker back almost immediately.

Step 3: Shed and requeue instead of queueing in memory

A shed delivery is not a failed delivery. It never left the process, no side effect occurred, and the correct response is to return it to the broker with a delay — not to increment a failure counter, not to feed the circuit breaker, and not to consume a retry attempt.

import random

from prometheus_client import Counter

SHED = Counter("webhook_deliveries_shed_total", "Rejected at the lane", ["endpoint"])
DELIVERED = Counter("webhook_deliveries_ok_total", "Delivered", ["endpoint"])


async def handle_message(message, client, bulkhead: Bulkhead) -> None:
    endpoint_id = message.endpoint_id

    async def _send():
        return await client.post(message.url, json=message.payload)

    try:
        response = await bulkhead.run(endpoint_id, _send, wait=0.5)
    except LaneFull:
        SHED.labels(endpoint=endpoint_id).inc()
        # Requeue with jittered delay. Does NOT count as a delivery attempt.
        delay = random.uniform(5.0, 20.0)
        await message.requeue(delay_seconds=delay, count_as_attempt=False)
        return

    if response.status_code < 300:
        DELIVERED.labels(endpoint=endpoint_id).inc()
        await message.ack()
    else:
        await message.retry(reason=f"http_{response.status_code}")

Two properties keep this stable. The requeue delay is jittered, because a fixed delay reconverges every shed message onto the same future instant and produces a periodic stampede against a lane that is still full. And count_as_attempt=False prevents a busy endpoint from exhausting its retry budget on shedding alone and landing in the dead-letter queue while perfectly healthy. This is admission control at the sender, complementary to the receiver-side signalling described in applying backpressure to webhook consumers.

Watch the shed rate as a first-class signal. Sustained shedding above roughly 20% of offered load for an endpoint means its lane is permanently undersized relative to what the tenant is sending, and the resolution is a conversation, not a config bump.

Step 4: Add a latency-triggered breaker for endpoints that never recover

Bulkheads bound the damage but never stop sending. For an endpoint that has been at 25 seconds for six hours, continuing to trickle deliveries into a growing backlog serves nobody. The control you need trips on latency, not error rate.

Triage tree for a slow endpoint If responses are mostly 2xx the answer is capacity control; if they are errors or timeouts the answer is a breaker and eventual auto-disable. Endpoint p95 over SLO mostly 2xx errors or timeouts Slow but successful Genuinely failing Shrink the lane shed and requeue Latency breaker trip on sustained p95 Error breaker back off hard Auto-disable after N hours The left branch is invisible to an error-rate breaker: every one of those responses was a 200.
Slow-but-successful and failing both need containment, but only the right-hand branch is reachable by conventional error-rate logic.
import time
from collections import deque


class LatencyBreaker:
    """Trips when p95 latency stays above a ceiling, regardless of status codes."""

    def __init__(self, ceiling_s: float = 15.0, window_s: float = 300.0,
                 min_samples: int = 20, cooldown_s: float = 600.0):
        self.ceiling_s = ceiling_s
        self.window_s = window_s
        self.min_samples = min_samples
        self.cooldown_s = cooldown_s
        self._samples: deque[tuple[float, float]] = deque()
        self._opened_at: float | None = None

    def record(self, duration_s: float) -> None:
        now = time.monotonic()
        self._samples.append((now, duration_s))
        cutoff = now - self.window_s
        while self._samples and self._samples[0][0] < cutoff:
            self._samples.popleft()

    def _p95(self) -> float:
        values = sorted(d for _, d in self._samples)
        if not values:
            return 0.0
        return values[min(len(values) - 1, int(len(values) * 0.95))]

    def allows(self) -> bool:
        now = time.monotonic()
        if self._opened_at is not None:
            if now - self._opened_at < self.cooldown_s:
                return False
            self._opened_at = None
            self._samples.clear()
            return True
        if len(self._samples) >= self.min_samples and self._p95() > self.ceiling_s:
            self._opened_at = now
            return False
        return True

Set ceiling_s from the endpoint’s own SLO, not a global constant — 15 seconds is aggressive for an ERP integration and absurdly generous for an edge function. Threshold selection for the error-rate breaker that guards the right-hand branch is covered in tuning circuit breaker thresholds for webhooks, and endpoints that never come back should end at auto-disabling failing webhook endpoints.

Step 5: Escalate to the consumer with evidence

Every control above degrades the slow consumer’s own delivery rate, so they must be told, with numbers, before they discover it as missing data. Expose per-endpoint p50/p95/p99 response time as measured by you, the current lane size, the shed rate, and the resulting sustainable throughput. The message that lands is arithmetic: “at your current p95 of 25 s and a lane of 4, you can receive 0.16 events per second; you are generating 40.”

Publish the same figures on a status endpoint the customer can poll, and include the lane size and effective rate in the delivery-failure webhook you send them. The remediation is always on their side — process asynchronously, return 202 Accepted immediately, do the ERP write out of band — and giving them the measurement makes that conversation short.

Verification and testing

Assert the two invariants that matter: a saturated lane sheds instead of blocking, and a slow lane does not affect a fast one.

import asyncio

import pytest


@pytest.mark.asyncio
async def test_lane_sheds_when_full():
    bulkhead = Bulkhead(default_size=2)

    async def slow():
        await asyncio.sleep(1.0)
        return "ok"

    running = [asyncio.create_task(bulkhead.run("ep_a", slow)) for _ in range(2)]
    await asyncio.sleep(0.05)
    with pytest.raises(LaneFull):
        await bulkhead.run("ep_a", slow, wait=0.1)
    assert bulkhead.in_use("ep_a") == 2
    await asyncio.gather(*running)
    assert bulkhead.in_use("ep_a") == 0


@pytest.mark.asyncio
async def test_slow_lane_does_not_block_fast_lane():
    bulkhead = Bulkhead(default_size=2)

    async def slow():
        await asyncio.sleep(2.0)

    async def fast():
        return "quick"

    blockers = [asyncio.create_task(bulkhead.run("ep_slow", slow)) for _ in range(2)]
    await asyncio.sleep(0.05)
    started = asyncio.get_running_loop().time()
    assert await bulkhead.run("ep_fast", fast, wait=0.1) == "quick"
    assert asyncio.get_running_loop().time() - started < 0.1
    await asyncio.gather(*blockers)

The second test is the regression guard for the whole page: if someone replaces the per-endpoint semaphore with a single global one, it fails immediately.

For a live check, confirm shedding is actually happening and is bounded:

# Shed rate should be non-zero for the slow endpoint and zero everywhere else.
curl -s localhost:9090/metrics | grep webhook_deliveries_shed_total

Failure modes and gotchas

Frequently Asked Questions

Does event ordering survive shedding and requeueing?

No, and it is important to say so out loud before enabling this. A shed event comes back after a jittered delay while events that arrived after it may already have been admitted, so the endpoint sees them out of order. Where per-subject order is part of the contract, key the lane on the ordering key rather than the endpoint and hold a single in-flight slot per key, which trades throughput for the guarantee.

How long can a lane stay saturated before the backlog itself becomes the incident?

Divide offered rate by sustainable rate and the answer is usually shorter than people expect. An endpoint offered 40 events per second and draining 0.16 accumulates roughly 3.4 million events a day, so the real deadline is your broker's retention window, not anyone's patience. Decide in advance which happens at that boundary, auto-disable or deliberate drop, because arriving there without a policy means the queue makes the choice for you.

Should lane sizes adapt automatically to measured latency?

Shrinking automatically is safe; growing automatically is not. A lane that expands the moment latency improves will feed the consumer more load, push its latency back up, and settle into an oscillation synchronised with their own autoscaler. Let the controller reduce a lane on sustained slowness and require a human decision to raise it, since raising it is a commitment of shared fleet capacity to one tenant.

Does a slow consumer also consume connection-pool capacity?

Yes, and it is the same arithmetic with a different scarce resource: a delivery that takes 25 seconds holds its socket for 25 seconds. If only the worker lane is capped, the endpoint still occupies a disproportionate share of the shared pool and pushes other destinations into pool-acquire timeouts. Set the per-host connection ceiling and the lane size from the same numbers so neither becomes the loose bound.

What lane should a newly registered endpoint get before any latency data exists?

The default, and no more. There is a temptation to be generous with unknown endpoints so their onboarding looks fast, but an unknown endpoint is exactly the one most likely to have a synchronous handler nobody has load-tested. One hour of production traffic gives you a p95 worth sizing from, and moving a lane up after that is a cheap, reversible change.

Would a tighter read timeout not solve this more simply than shedding?

It solves a different problem and adds one. Cutting the read budget frees the worker sooner, but the request has already reached the consumer, so their handler keeps running and your retry may duplicate work they have completed. Shedding at admission costs the consumer nothing because the request was never sent, which is why it belongs before the timeout in the order of controls.