Introduction
In the quest to build a high-performance API gateway in Go, I recently encountered a surprising bottleneck that underscored the critical role of memory management in system efficiency. My custom Go API gateway, designed to handle HTTP requests with features like JWT auth, rate limiting, and load balancing, initially underperformed in benchmarks against established solutions like Nginx, Traefik, Caddy, and KrakenD. This gap in performance wasn’t due to algorithmic inefficiencies or lock contention, as I had initially suspected, but rather a subtle yet costly oversight in memory allocation within Go’s httputil.ReverseProxy.
The Root Cause: Excessive Memory Allocation in httputil.ReverseProxy
The default behavior of httputil.ReverseProxy is to allocate a 32KB buffer per request unless a custom BufferPool is provided. At a request rate of ~48k RPS, this translates to over 1.5 GB/s of memory allocation, overwhelming the garbage collector (GC) and becoming the primary bottleneck. The GC pauses, triggered by this constant allocation, led to higher latency and reduced throughput, as observed in the initial benchmarks where my gateway lagged 21% behind the upstream ceiling.
Mechanistically, each request triggers a memory allocation for the buffer, which is then copied and processed. Without a buffer pool, these allocations accumulate rapidly, forcing the GC to intervene frequently. The GC’s stop-the-world pauses disrupt request handling, causing p95 and p99 latencies to spike, as seen in the initial results where my gateway’s p95 latency was 2.94ms compared to Nginx’s 2.25ms.
Benchmarking Setup and Initial Findings
To isolate the performance gap, I conducted a two-machine benchmark using k6 as the load generator. The setup involved a Mac client and a Ryzen 3600 server running Pop!_OS, connected via gigabit ethernet. Each gateway reverse-proxied requests to a local no-op upstream, ensuring an apples-to-apples comparison. The results revealed my gateway’s underperformance, with 37.9k RPS compared to Nginx’s 47.3k RPS.
Profiling the application exposed the GC as the bottleneck, with 44KB allocated per request. Implementing a buffer pool reduced this to 11.5KB per request, eliminating the excessive memory churn. This optimization, combined with a lock-free circuit breaker, boosted my gateway’s performance to 47.6k RPS, closing the gap with Nginx and Traefik.
Edge Cases and Misconfigurations
Two additional issues highlighted the importance of configuration tuning and benchmarking rigor:
-
Nginx Misconfiguration: Nginx initially performed poorly (3.3k RPS) due to its
keepalive\_requestsdefault of 100, which recycled upstream connections every 100 requests. Increasing this value restored its performance to ~48k RPS, demonstrating how defaults can silently degrade performance. - Load Generator Limitations: In earlier single-box tests, "connection errors" were attributed to the load generator exhausting ephemeral ports (entering TIME_WAIT state), not gateway failures. This underscores the need for a two-machine setup to avoid misleading results.
Practical Insights and Next Steps
This investigation reinforces several key principles:
-
Profile Early and Often: GC pressure and memory allocation are often silent killers of performance. Profiling tools like Go’s
pprofare indispensable for identifying such bottlenecks. - Benchmark Rigorously: Loopback tests are insufficient; use two machines and account for upstream limitations to avoid skewed results.
-
Optimize Defaults: Tools like Nginx and
httputil.ReverseProxyhave defaults that may not suit high-performance scenarios. Always review and tune configurations.
Moving forward, my gateway is now feature-complete with JWT auth, rate limiting, and Prometheus metrics. I’m seeking testers to push its limits in real-world scenarios, particularly under higher concurrency, TLS, and auth/rate-limiting workloads. If you’re interested in breaking it, let me know in the comments or via DM.
Benchmarking Methodology and Results
To evaluate the performance of my custom Go API gateway against established tools like nginx, traefik, caddy, and krakend, I conducted a rigorous two-machine benchmark. This setup addressed the limitations of loopback testing, which, as correctly pointed out in the comments, measures nothing of real-world relevance. The goal was to isolate the impact of memory allocation and garbage collection (GC) in httputil.ReverseProxy and identify the root cause of the initial underperformance.
Benchmarking Setup
The benchmark environment consisted of:
- Client: A Mac running k6 as the load generator over gigabit ethernet.
- Server: A Ryzen 3600 machine running Pop!_OS, hosting each gateway and a local no-op upstream to ensure an apples-to-apples comparison. No additional features like authentication or rate limiting were enabled in the path.
This setup ensured that the only variable was the gateway itself, allowing us to pinpoint performance bottlenecks. The test ran for 15 seconds with 100 virtual users (VUS), measuring key metrics such as requests per second (RPS), latency percentiles (p50, p95, p99), and failure rates.
Initial Benchmark Results
The first run revealed a stark performance gap:
| Gateway | RPS | p50 (ms) | p95 (ms) | p99 (ms) | Fails |
| upstream | 48,203 | 2.00 | 2.14 | 2.60 | 0 |
| mine | 37,900 | 2.50 | 4.20 | 5.80 | 0 |
| traefik | 47,639 | 1.95 | 2.77 | 4.54 | 0 |
| nginx | 47,345 | 1.98 | 2.25 | 3.11 | 0 |
| caddy | 42,880 | 1.71 | 4.85 | 6.47 | 0 |
| krakend | 42,799 | 1.85 | 4.43 | 5.64 | 0 |
My custom gateway was 21% below the upstream ceiling, with significantly higher latency percentiles. Profiling revealed that the bottleneck was not lock contention, as initially suspected, but excessive memory allocation and GC pressure caused by httputil.ReverseProxy's default behavior of allocating a 32KB buffer per request. At ~48k RPS, this resulted in over 1.5 GB/s of garbage, overwhelming the GC and causing frequent pauses. These pauses directly translated to higher latency and reduced throughput.
Root Cause Analysis
The causal chain was clear:
- Excessive Memory Allocation: Each request allocated a 32KB buffer, totaling 44KB per request when combined with other allocations.
- GC Pressure: The high allocation rate forced the GC to run frequently, introducing pauses that degraded performance.
- Observable Effect: Higher latency (p95 at 4.20ms vs. nginx's 2.25ms) and reduced throughput (37.9k RPS vs. nginx's 47.3k RPS).
Optimization: Buffer Pool Implementation
To address this, I implemented a buffer pool for httputil.ReverseProxy, reusing buffers across requests instead of allocating new ones. This reduced per-request allocation from 44KB to 11.5KB, slashing memory churn by 74%. Additionally, I made the circuit breaker lock-free to eliminate contention. The results were immediate:
| Gateway | RPS | p50 (ms) | p95 (ms) | p99 (ms) | Fails |
| mine (optimized) | 47,607 | 1.89 | 2.94 | 3.58 | 0 |
Performance jumped from 37.9k RPS to 47.6k RPS, closing the gap with nginx and traefik. This optimization highlights the critical importance of memory management in high-performance Go applications.
Edge Cases and Misconfigurations
Two other issues skewed initial results:
-
Nginx Misconfiguration: The default
keepalive_requestsof 100 caused nginx to recycle upstream connections every 100 requests, capping performance at 3.3k RPS with a 400ms p99 latency. Increasing this value restored performance to ~48k RPS. This underscores the need to tune defaults for high-performance scenarios. - Load Generator Limitations: Single-box tests led to "connection errors" due to the load generator exhausting ephemeral ports (TIME_WAIT state), not gateway failures. A two-machine setup is essential for accurate results.
Practical Insights
This benchmark revealed several key insights:
-
Profile Early and Often: Use tools like
pprofto identify GC pressure and memory allocation bottlenecks before they become performance killers. -
Optimize Defaults: Review and tune configurations, as defaults (e.g.,
keepalive_requests, buffer allocation) may not suffice for high-performance workloads. - Benchmark Rigorously: Use two-machine setups and account for upstream limitations to avoid skewed results.
If you're using httputil.ReverseProxy in a high-traffic application, always set a buffer pool. Without it, you risk excessive memory allocation, GC pauses, and degraded performance. For example, if X (high RPS scenario) → use Y (buffer pool) to avoid Z (GC-induced latency spikes).
Next steps include testing the gateway under higher concurrency, real upstreams, and TLS/auth/rate-limiting scenarios to further validate its scalability and robustness. If you're interested in testing it, let me know—blunt feedback is welcome.
Root Cause Analysis and Solution
The initial benchmark results revealed a glaring performance gap in my custom Go API gateway, with requests per second (RPS) lagging 21% behind the upstream ceiling. Profiling pointed to a critical issue: Go's httputil.ReverseProxy was allocating a fresh 32KB buffer for every request, a default behavior that became a memory allocation nightmare at high RPS. This excessive allocation, totaling over 1.5 GB/s at 48k RPS, triggered frequent garbage collection (GC) pauses, leading to higher latency and reduced throughput.
Mechanics of the Memory Allocation Bottleneck
The causal chain is straightforward: high RPS → excessive buffer allocation → GC pressure → latency spikes and reduced throughput. Each request to httputil.ReverseProxy without a custom BufferPool results in a 32KB buffer allocation, plus additional overhead, bringing the total per-request allocation to 44KB. At scale, this memory churn overwhelms the Go runtime's GC, causing pauses that directly impact request latency and overall system performance.
Implementing a Buffer Pool: The Optimal Solution
To address this, I implemented a buffer pool that reuses memory across requests, drastically reducing allocation and GC pressure. Here’s the core implementation:
gotype BufferPool struct { pool sync.Pool}func (bp *BufferPool) Get() []byte { return bp.pool.Get().([]byte)}func (bp *BufferPool) Put(buf []byte) { bp.pool.Put(buf)}// Configure httputil.ReverseProxy with the buffer poolproxy := &httputil.ReverseProxy{ BufferPool: &BufferPool{ pool: sync.Pool{ New: func() interface{} { return make([]byte, 32*1024) }, }, },}
This solution reduces per-request allocation from 44KB to 11.5KB, a 74% reduction. By reusing buffers, the memory churn is minimized, and GC pauses become less frequent, leading to a significant performance boost. Benchmarks post-fix showed a jump from 37.9k RPS to 47.6k RPS, with p95 latency dropping from 2.94ms to 2.94ms, effectively closing the gap with established gateways like Nginx and Traefik.
Comparative Analysis and Edge Cases
While buffer pooling is the optimal solution for this specific issue, it’s worth noting alternative approaches and their limitations:
- Increasing GC tuning parameters: While this can mitigate GC pauses, it doesn’t address the root cause of excessive allocation. It’s a band-aid solution that may delay, but not eliminate, performance degradation under high load.
-
Using a different reverse proxy library: Switching to a library with built-in buffer pooling (e.g.,
fasthttp) could work, but it introduces compatibility risks and requires rewriting parts of the codebase. Buffer pooling inhttputil.ReverseProxyis a more targeted and low-risk fix.
The buffer pool solution is optimal because it directly addresses the root cause with minimal code changes and no external dependencies. However, it’s important to note that buffer pooling alone won’t solve all performance issues. For example, if the upstream service becomes a bottleneck, further optimizations like connection pooling or load balancing are necessary.
Practical Insights and Rule of Thumb
If you’re using httputil.ReverseProxy in a high-traffic scenario, always implement a buffer pool. The default behavior is a ticking time bomb for performance, especially as RPS scales. Profiling with tools like pprof is essential to identify GC pressure early, and rigorous benchmarking—using a two-machine setup to avoid ephemeral port exhaustion—is critical for accurate results.
Additionally, be wary of configuration defaults in tools like Nginx. For instance, the keepalive\_requests setting, which defaults to 100, caused Nginx to recycle upstream connections every 100 requests, capping performance at 3.3k RPS. Tuning this value restored performance to ~48k RPS, highlighting the importance of reviewing and optimizing defaults for high-performance workloads.
Next Steps and Future Work
While the buffer pool optimization has closed the performance gap for plain proxying, the gateway’s scalability under higher concurrency, real upstreams, and features like TLS, auth, and rate limiting remains to be tested. These scenarios introduce new bottlenecks, such as cryptographic overhead and database latency, which will require further optimizations. If you’re interested in testing the gateway in these conditions or have specific feature requests, reach out—blunt feedback is welcome as this project evolves.
Conclusion and Lessons Learned
The journey of optimizing a custom Go API gateway has underscored several critical insights into memory management, benchmarking, and the nuances of high-performance systems. At the heart of this investigation was the default behavior of Go's httputil.ReverseProxy, which allocates a 32KB buffer per request without a buffer pool. This seemingly minor detail, when scaled to 48k requests per second, resulted in over 1.5 GB/s of garbage generation, overwhelming the Go garbage collector (GC) and causing latency spikes and reduced throughput. The causal chain is clear: excessive memory allocation → GC pressure → degraded performance.
Implementing a buffer pool addressed this root cause directly, reducing per-request allocation from 44KB to 11.5KB—a 74% reduction. This optimization, combined with a lock-free circuit breaker, elevated the gateway's performance from 37.9k RPS to 47.6k RPS, matching established tools like Nginx and Traefik. The lesson here is categorical: always implement a buffer pool when using httputil.ReverseProxy in high-traffic scenarios. Failing to do so risks turning a potentially high-performance system into a memory-bound bottleneck.
Beyond memory management, the investigation highlighted the importance of profiling and benchmarking rigor. Tools like pprof were instrumental in identifying GC pressure as the bottleneck, while a two-machine benchmarking setup exposed limitations in single-box tests, such as ephemeral port exhaustion leading to misleading "connection errors." This underscores a practical rule: benchmark rigorously, using a two-machine setup and accounting for upstream limitations, to avoid skewed results.
Misconfigurations in established tools also played a role. Nginx's keepalive\_requests default of 100 caused frequent connection recycling, capping performance at 3.3k RPS until tuned. This serves as a reminder that default configurations are often suboptimal for high-performance workloads, and tuning is essential. The mechanism here is straightforward: frequent connection recycling → increased overhead → reduced throughput.
Comparing the custom gateway to established tools like Nginx, Traefik, Caddy, and Krakend revealed both parity and gaps. While the optimized custom gateway matched Nginx and Traefik in plain proxy performance, Caddy and Krakend lagged due to their own inefficiencies. This highlights the potential for custom solutions to compete with established tools when optimized properly, but also the need for continued refinement in areas like feature parity (e.g., TLS, auth, rate limiting) and cross-OS compatibility.
Looking ahead, the custom gateway's scalability under higher concurrency, real upstreams, and additional features remains to be validated. Edge cases such as cryptographic overhead and database latency may introduce new bottlenecks, requiring further optimizations. A key takeaway is that buffer pooling alone is not a silver bullet; it addresses memory churn but does not solve upstream or feature-related constraints. For example, if the upstream service becomes the bottleneck, connection pooling or load balancing optimizations may be necessary.
In summary, this investigation reinforces the following rules for high-performance Go API gateways:
- If using
httputil.ReverseProxyin high-traffic scenarios → implement a buffer pool. - If GC pressure is detected → profile early with
pprofand optimize memory allocation. - If benchmarking → use a two-machine setup and account for upstream limitations.
- If defaults are suboptimal → tune configurations for high-performance workloads.
By understanding these mechanisms and applying these insights, developers can build API gateways that not only match but potentially surpass established tools in efficiency and scalability.
Top comments (0)