Six OOMs and a Connection Pool

The Memory Benchmark · Part 23

Part 23 of the Micronaut native image series. Follows Part 22, where the reactive ports reached throughput parity across three frameworks. Assumes familiarity with Spring’s RestClient and GraalVM native image.

The same 28-API fan-out app runs comfortably at 256 MB on Quarkus. Runs comfortably on Micronaut. On the imperative Spring Boot 4.1 port – Spring MVC, virtual threads, JdbcClient – it OOM’d. Then I fixed it and it OOM’d differently. Six benchmark runs. Six different failure modes. Each one peeling back a layer of what “choosing an HTTP client” actually means in a memory-constrained native image.

The scorecard

#ClientrpsCPU avgRSS (MB)OOM restarts
1SimpleClientHttpRequestFactory74.441%2080
2JdkClientHttpRequestFactory2263
3JDK HttpClient + vthread executor3.9%4
4JDK HttpClient + Semaphore33.82.3%0
5JDK HttpClient + lower heap cap9
6Apache HC5 + pooled connections34.52440

Same app. Same k6 profile (ramp to ~300 VUs, then 500 req/s constant arrival). Same 256 MB container, swap disabled, Serial GC. Same Go mock server answering 28 endpoints with 10-150 ms latency.

Every row is a real benchmark run measured today. The dashes are runs that crash-looped too hard to produce stable averages.

Run 1: HttpURLConnection survived by being slow

SimpleClientHttpRequestFactory wraps java.net.HttpURLConnection. Spring’s simplest client. No connection pooling. No HTTP/2.

It fit 256 MB. It also did 74.4 req/s at 41% average CPU – with peaks hitting 93%. Only ~30 platform threads. p95 latency: 3.9 seconds.

Something was serializing the fan-out. HttpURLConnection’s keep-alive cache is internally synchronized. At 300 VUs submitting 28 calls each through a virtual-thread executor, the contention on those internal locks is extraordinary. Throughput throttled. CPU burned on lock contention, not useful work.

The memory “fit” because the contention suppressed concurrency. Fewer requests in flight at any instant means less live heap. HttpURLConnection throttled itself into survival.

Run 2: JDK HttpClient, now with 376 threads

Switch to JdkClientHttpRequestFactory, backed by java.net.http.HttpClient. Modern. Async internally. HTTP/2 capable. Built into the JDK since Java 11.

CPU dropped. Latency improved. Three OOM restarts.

The JDK HttpClient’s default executor is an unbounded cached platform-thread pool. Under 300 VUs x 28 calls, it spawned 376 platform threads. Each carries a native stack (typically 512 KB-1 MB). Three hundred seventy-six of them, plus their thread-local buffers, plus the heap, plus the native image itself – the container didn’t have a chance.

Run 3: virtual-thread executor fixes threads, reveals heap

HttpClient httpClient = HttpClient.newBuilder()
        .executor(Executors.newVirtualThreadPerTaskExecutor())
        .connectTimeout(Duration.ofSeconds(3))
        .build();

Platform threads: 376 to 51. CPU average: 3.9%. Full-survey p95: 2.7 seconds. Clean, fast, correct.

Four OOM restarts.

The thread stacks are gone. Now the heap is the problem. At 300 VUs, each request fans out to 28 API calls. Each in-flight call holds a request object, a response buffer, connection state. The JDK HttpClient has no upper bound on concurrent connections. At peak: 300 x 28 = 8,400 in-flight request objects. GraalVM’s Serial GC defaults the heap to 80% of the container – ~205 MB at 256 MB. The live objects exceeded it.

The container OOM between docker stats 2-second samples. RSS showed 226 MB (after a restart). The real peak was higher.

Run 4: semaphore bounds concurrency, kills throughput

Wrap each client group in a Semaphore. Science clients: 200 permits. Others: 50. Sized to match Quarkus’s Vert.x connection pools.

Zero OOM restarts. Throughput: 33.8 req/s. p95: 7.9 seconds. CPU: 2.3%.

The semaphore bounds concurrency. Fewer in-flight requests means less live heap. The heap fits.

But a semaphore around a blocking HTTP call has no connection reuse. Every call opens a new TCP connection, does one request, closes it. The fan-out serializes behind the permit count. It’s not a pool – it’s a line. Throughput collapses because the app spends all its time in TCP handshakes and permit-acquire waits instead of doing work.

Run 5: lowering the heap cap was exactly backwards

Hypothesis: the heap cap is too generous. Lower -XX:MaxHeapSize from 128 MB to 96 MB. Leave more room for off-heap overhead.

Nine restarts. The error message is unambiguous:

java.lang.OutOfMemoryError: Garbage-collected heap size exceeded.

This isn’t a container kill. This is a heap OOM at the cap. The app genuinely wants ~150 MB of heap under the 28-way fan-out because the JDK HttpClient keeps thousands of in-flight request objects alive. Lowering the cap doesn’t reduce the live set – it just OOMs earlier.

The fix isn’t less heap. It’s fewer live objects. Bound the client, not the heap.

Run 6: Apache HttpClient5, pooled and bounded

PoolingHttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create()
        .setMaxConnTotal(maxConn)
        .setMaxConnPerRoute(maxConn)
        .setDefaultConnectionConfig(ConnectionConfig.custom()
                .setConnectTimeout(Timeout.ofMilliseconds(connectMs))
                .build())
        .build();

CloseableHttpClient httpClient = HttpClients.custom()
        .setConnectionManager(cm)
        .setDefaultRequestConfig(RequestConfig.custom()
                .setResponseTimeout(Timeout.ofMilliseconds(readMs))
                .build())
        .build();

Five RestClient beans, each with its own pool: science at 200 connections, nasa-neo at 100, the rest at 50. A RetryInterceptor handles 5xx and transport failures with exponential backoff – the Spring equivalent of Micronaut’s @Retryable.

Zero OOM restarts. RSS: 244 MB. Throughput: 34.5 req/s. Full-survey p95: 10 seconds.

Stable. Correct. Memory-bound.

The pool does what the semaphore couldn’t: it bounds and reuses connections. TCP handshakes amortized. Keep-alive honored. In-flight request objects capped at pool size. The heap fits 256 MB because the pool limits how many concurrent requests can exist.

Why Quarkus fits and Spring doesn’t

At 256 MB on the identical 28-API fan-out:

Spring Boot 4.1Quarkus 3.33
Throughput34.5 req/s94.7 req/s
RSS peak244 MB161 MB
Headroom12 MB95 MB

Quarkus gives each @RegisterRestClient interface its own Vert.x connection pool. Six science clients, each with connection-pool-size=200. That’s ~1,200 effective connections for the science group alone. The pools are bounded and per-interface, so the fan-out runs wide without sharing a bottleneck.

Spring’s six science clients share one RestClient backed by one PoolingHttpClientConnectionManager capped at 200 connections. The 24 science API calls compete for 200 slots instead of 1,200. Throughput bottlenecks at the pool.

Raising the pool to 1,200 would fix throughput. It would also blow past 256 MB. Spring’s per-in-flight-request heap footprint is higher – the RestClientHttpComponentsClientHttpRequestFactory → Apache HC5 stack allocates more per connection than Vert.x’s netty-backed client. At 256 MB, there isn’t room for both a larger pool and the heap those connections need.

At 512 MB, Spring would match Quarkus. This is a genuine footprint difference under a pathological workload, not a misconfiguration.

The lesson

In a memory-constrained native image, your blocking HTTP client choice is the dominant memory variable. Not the framework. Not the GC. Not the heap cap.

The progression:

  1. No pool (HttpURLConnection) – survives by being too slow to fill memory.
  2. Unbounded async (JDK HttpClient) – fast, OOMs on thread stacks or heap.
  3. Bounded but no reuse (Semaphore) – fits memory, kills throughput.
  4. Bounded with reuse (Apache HC5 pool) – fits memory, preserves throughput. The only correct answer.

Every other knob – heap cap, GC choice, thread count – is downstream of this decision. Get the client wrong and you’re tuning symptoms.

Sources