← Back to list

Reasons API Gateways Become Bottlenecks When Teams Keep Adding Just One More Filter?

Anh Trần Tuấn · 2026-05-09 09:00 · 34 claps · 8.0 min read paywalled
#api-gateway #gateway #performance #backend #https
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Reasons API Gateways Become Bottlenecks When Teams Keep Adding Just One More Filter?

Source: Reasons API Gateways Become Bottlenecks When Teams Keep Adding Just One More Filter?

The first time a team tacks on “just one more filter,” nobody notices the additional 3–10 ms. A month later there are ten of those, and suddenly pages that used to be 40 ms look more like 400 ms for real users and 2–4 seconds for the unlucky ones hitting the p99. This article unpacks why that happens, how small design choices multiply into system-wide bottlenecks, and what you can do in Java-based gateways to prevent the slow creep from becoming a production crisis.

1. The anatomy of “one more filter”

Teams treat filters as low-risk insertion points: authentication, tracing, rate-limit check, header enrichment, request validation, transformation, metrics, security scanning, routing decisions. Each filter seems tiny, but they compose in a serial pipeline. The canonical mental model — filters execute in order, each adds CPU time and possible blocking work — misses nuanced performance characteristics that turn a small CPU cost into a systemic bottleneck.

1.1 Serial composition amplifies cost

If each filter takes Ti ms, the total processing time is sum(Ti) plus overhead. Overhead includes request parsing, object allocation, copying request body, thread scheduling, and inter-filter coordination. A pipeline of N filters increases latency linearly for CPU-bound work, but non-linear effects arise for blocking operations, memory pressure, and tail latency amplification.

1.2 Filters are not free: hidden costs

Hidden costs include:

  • Request/response buffering and duplication (byte[] or ByteBuffer allocations).
  • Context switching when filters use separate thread pools or synchronous blocking I/O.
  • Lock contention on shared caches or metrics collectors.
  • Extra serialization/deserialization cycles (JSON parsing/rewriting).
  • Tail latency magnification via queueing delays and head-of-line blocking.

2. Execution models and why they matter

Two primary execution models appear in gateway implementations: blocking (thread-per-request) and non-blocking/reactive (event-loop with async). Each model has different failure/ bottleneck modes when filters accumulate.

2.1 Thread-per-request (blocking)

Traditional servlet-based gateways (Tomcat/Jetty) use a thread pool. If filters perform blocking I/O (DB lookups, synchronous token introspection), threads get occupied and cannot service new requests. Adding filters that block increases the probability of exhausting the pool, producing queueing delays and timeouts.

2.2 Event-loop / reactive (non-blocking)

Reactive gateways (Netty, Reactor, Vert.x) favor a small number of event threads. Blocking operations on the event thread are catastrophic: a single blocking filter stalls all concurrent requests on that event loop. Even non-blocking expensive CPU work can starve the event loop and inflate latency.

3. Concrete Java example: a naive filter chain

Below is a minimal, synchronous filter chain pattern representative of many gateway implementations. Read the comments and the following analysis carefully; the code shows per-filter invocation, allocations, and how exceptions or finally blocks can add cost.

public interface Filter {    void doFilter(Request req, Response res, FilterChain chain) throws Exception;}public class FilterChain {    private final List



      filters;    private final int index;    public FilterChain(List  







        filters) { this(filters, 0); }    private FilterChain(List   







          filters, int index) { this.filters = filters; this.index = index; }    public void doFilter(Request req, Response res) throws Exception {        if (index == filters.size()) {            // final handler: route to backend            routeToBackend(req, res);            return;        }        Filter f = filters.get(index);        // create a new chain object for next element (allocation per filter)        FilterChain next = new FilterChain(filters, index + 1);        f.doFilter(req, res, next);    }}// Example filterpublic class AuthFilter implements Filter {    public void doFilter(Request req, Response res, FilterChain chain) throws Exception {        if (!isAuthorized(req)) {            res.setStatus(401);            return;        }        chain.doFilter(req, res);    }}

Explanation and costs:

  • Per-filter allocation: creating a new FilterChain object for each filter/method invocation results in N extra allocations per request. Even if allocations are short-lived, they increase GC pressure, especially under throughput.
  • Call-stack depth and JVM inlining: deep chains can defeat method inlining and increase CPU cycles per request.
  • Short-circuiting behavior: when a filter returns early (e.g., rejects auth), it reduces cost — good — but partial short-circuits increase control-flow complexity and can produce uneven latency distributions.

3.1 Optimization opportunity

Avoid per-invocation object creation by using a single mutable index or an iterative loop inside FilterChain. However, that changes recursion semantics and may complicate exception handling.

4. Blocking inside filters: the real trap

A filter that performs synchronous network or disk I/O (token validation via HTTP, DB lookup, rate-limit store access) is problematic. Example below shows a filter that synchronously calls an external auth service on the request path.

public class RemoteAuthFilter implements Filter {    private final HttpClient httpClient; // blocking client    public void doFilter(Request req, Response res, FilterChain chain) throws Exception {        String token = req.getHeader("Authorization");        // blocking HTTP call on gateway thread        AuthResponse auth = httpClient.post("/introspect", token);        if (!auth.isValid()) {            res.setStatus(401);            return;        }        chain.doFilter(req, res);    }}

Why this is dangerous:

  • Thread pool exhaustion: Each blocked request holds a thread. When concurrency exceeds pool size, new requests queue in the OS/JVM or are rejected.
  • Queueing amplification: Waiting threads build up; when one thread unblocks, multiple queued requests start and contend for CPU/Garbage Collector, causing cascaded delays.
  • Backpressure blindness: The gateway may appear healthy until latency spikes and RPS collapses — classic “happy when idle, brittle when loaded” failure mode.

4.1 A more realistic sample: blocking DB lookup with connection pool

public class AccountFilter implements Filter {    private final DataSource ds; // JDBC datasource    public void doFilter(Request req, Response res, FilterChain chain) throws Exception {        try (Connection c = ds.getConnection()) { // may block if pool exhausted            PreparedStatement ps = c.prepareStatement("SELECT flags FROM accounts WHERE id=?");            ps.setString(1, req.getHeader("x-acc"));            ResultSet rs = ps.executeQuery();            if (rs.next() && rs.getBoolean(1)) {                chain.doFilter(req, res);            } else {                res.setStatus(403);            }        }    }}

Connection pool behavior matters: a small pool causes getConnection() to block; a large pool increases total memory/threads and may overload DB. Filters that synchronously call shared services should be treated as expensive resources and either rate-limited, cached, or moved out of the hot path.

5. Asynchronous handling and pitfalls

Moving blocking operations off the request thread seems like the obvious fix, but it’s not free. Thread hopping, context capture, and lifecycle management introduce complexity. Below is a Java example using CompletableFuture to offload work to a bounded executor.

public class AsyncAuthFilter implements Filter {    private final ExecutorService offloadPool;    private final HttpClient asyncHttpClient;    public void doFilter(Request req, Response res, FilterChain chain) throws Exception {        CompletableFuture



      f = CompletableFuture.supplyAsync(() -> {            // run blocking call off main thread            return asyncHttpClient.post("/introspect", req.getHeader("Authorization"));        }, offloadPool);        // register callback to continue processing when auth completes        f.thenAccept(auth -> {            try {                if (!auth.isValid()) {                    res.setStatus(401);                } else {                    chain.doFilter(req, res);                }            } catch (Exception e) {                res.setStatus(500);            }        });        // Important: must ensure request lifecycle isn't closed by caller thread.    }}

Trade-offs and pitfalls:

  • Thread pool sizing and queuing: The offload pool must be sized properly; otherwise you move the blocking to another limited resource.
  • Request lifecycle complexity: The gateway must preserve request/response objects across async boundaries, manage timeouts, and handle cancellations.
  • Context propagation: Tracing IDs, security context, and classloader context must be propagated manually if not provided by a framework.
  • Increased latency and resource usage: Thread handoff and callback scheduling add overhead — especially for short requests where the offload cost may exceed the original blocking time.

6. Memory, GC, and tail latency concerns

Every filter often allocates temporary objects: header wrappers, enriched DTOs, modified bodies. Under high throughput this increases allocation pressure and triggers GC cycles that add pause times. Tail latencies (p95/p99) disproportionately suffer from GC and jitter. When filters buffer request bodies (to allow multiple reads or transformations), memory spikes correlate with large concurrent requests and can cause out-of-memory or garbage collector thrashing.

6.1 Example: body buffering filter

public class BodyBufferingFilter implements Filter {    public void doFilter(Request req, Response res, FilterChain chain) throws Exception {        // naive: read entire InputStream into byte[]        byte[] body = req.getInputStream().readAllBytes(); // allocates potentially large array        // mutate, validate, or parse body        req.setAttribute("bufferedBody", body);        chain.doFilter(req, res);    }}

Buffers are useful for transformations but must be bounded and governed by policies (max size, streaming transforms) to avoid memory pressure.

7. Observability: how to detect the filter-bloat problem

You cannot fix what you cannot measure. Key signals:

  • Latency percentiles (p50, p90, p95, p99) per filter and end-to-end.
  • Thread pool saturation metrics: active threads, queue length, waiting time.
  • GC metrics and allocation rate (bytes/sec, promoted objects).
  • Backend call latencies and concurrent connection counts (for auth, DB, cache).
  • Sampling and flame graphs to identify hot methods.

Instrument filters to emit timing and resource annotations. Correlate traces so you can see which filter causes spikes in p99. Short-lived filters that allocate and return quickly are cheaper than those that make remote calls or block for IO.

8. Practical mitigation strategies

Here are concrete, prioritized strategies to prevent “one more filter” from breaking the gateway.

  1. Audit and categorize filters: classify filters as CPU-bound, blocking I/O, memory-heavy, or short-circuiting. Prioritize removing or moving blocking filters out of the hot path.
  2. Short-circuit early: place cheap rejecting filters (auth token presence, IP blacklist) before expensive operations.
  3. Combine filters: combine adjacent filters that operate on the same data to reduce traversal and allocations.
  4. Use non-blocking clients or move to async: prefer reactive clients or offload to bounded async pools — but treat offload pools as first-class resources to size and observe.
  5. Cache expensive results: token introspection or account lookups are prime caching candidates; use TTLs and proper invalidation.
  6. Rate-limit and shed load at the edge: prevent overload by rejecting non-critical requests earlier (429) rather than blocking threads downstream.
  7. Stream instead of buffer: operate on InputStream/ByteBuf without full buffering when possible.
  8. Limit per-request allocations: reuse buffers (Netty ByteBuf pooling), avoid creating wrapper objects per filter when possible.
  9. Move heavy logic to dedicated services or sidecars: heavy transformations, analytics, or scanning can be done asynchronously or in a sidecar microservice.
  10. Adopt circuit breakers and timeouts: fail fast on slow downstreams to avoid cascading queues.

8.1 Example: caching token introspection

public class CachingTokenFilter implements Filter {    private final LoadingCache



      cache = Caffeine.newBuilder()            .expireAfterWrite(Duration.ofSeconds(5))            .maximumSize(10_000)            .build(this::introspect);    public void doFilter(Request req, Response res, FilterChain chain) throws Exception {        String token = req.getHeader("Authorization");        AuthResponse auth = cache.get(token); // fast, mostly in-memory        if (!auth.isValid()) {            res.setStatus(401);            return;        }        chain.doFilter(req, res);    }    private AuthResponse introspect(String token) {        // blocking call executed by cache miss thread        return externalHttpClient.post("/introspect", token);    }}

Caching moves work off the critical path for common cases. But beware cache stampede, stale data for token revocation, and memory sizing trade-offs.

9. Trade-offs: what you lose when you optimize

Every mitigation has costs:

  • Combining filters reduces modularity and ownership clarity.
  • Offloading increases complexity: context propagation, error handling, and debugging are harder.
  • Caching can introduce staleness and consistency problems (revoked tokens).
  • Sidecars and remote services shift load rather than remove it — you must provision and operate them.

Decisions should be based on measured bottlenecks and organizational constraints: reliability vs freshness vs developer velocity.

10. Checklist and rules of thumb

  1. Measure before changing: p50/p95/p99, thread pools, GC, backend latencies.
  2. Prioritize fixes that reduce blocking and allocations in the hot path.
  3. Cache carefully: short TTLs for auth introspection, validate revocation requirements.
  4. Make filters idempotent and stateless where possible to enable easier offloading.
  5. Avoid per-request object allocation patterns in hot code paths (e.g., avoid creating chain objects per filter).
  6. Design filters with clear SLAs: is it latency-sensitive? Does it require strong consistency?
  7. Use canary deployments and load testing (locust, Gatling) to reveal queuing and tail behaviors.

10.1 Quick mental model

Think about each filter as adding two things: work (CPU/IO) and fragility (new external dependency or memory pressure). If a filter is cheap and pure CPU, it mostly scales linearly. If it adds blocking I/O or heavy allocations, treat it like adding a new backend service in the critical path and analyze it accordingly.

11. Final thoughts

API gateways are powerful coordination points, but they are also natural choke points. The temptation to “just add one more filter” is a common organizational pattern that gradually centralizes responsibility and risk. Keep filters minimal, instrumented, and categorized; prefer doing expensive or stateful work outside the hot path; and always profile under realistic load to avoid nasty surprises in p99 latency. If you have specific gateway code or an incident you’d like help diagnosing, comment below and I’ll help walk through it.

If my articles have been valuable to you, I’d be deeply grateful for your support at here . Your encouragement fuels my passion for creating even more insightful and high-quality content!


메타데이터
post_id
ea9d95cff9b2
slug
reasons-api-gateways-become-bottlenecks-when-teams-keep-adding-just-one-more-filter-ea9d95cff9b2
url
https://medium.com/@tuananhbk1996/reasons-api-gateways-become-bottlenecks-when-teams-keep-adding-just-one-more-filter-ea9d95cff9b2
canonical_url
https://medium.com/@tuananhbk1996/reasons-api-gateways-become-bottlenecks-when-teams-keep-adding-just-one-more-filter-ea9d95cff9b2
author_url
https://medium.com/@tuananhbk1996
status
ok
fetched_at
2026-06-09 15:37:30