🚨 Problem Introduction
Imagine you’re building a Spring Boot Order Service that calls three downstream services:
- Inventory Service
- Pricing Service
- Shipping Service
You decide to use Spring’s new RestClient (Spring 6 / Boot 3). Locally, everything works perfectly fine. But in production, under load, issues appear:
- Requests pile up
- Threads block waiting for connections
- Users face delays and even timeouts
What’s going on?
🔎 As most likely you know based on my previous blog Avoid Spring RestTemplate Default Implementation to Prevent Performance Impact. The
RestTemplate defaults only 2 total global connections (without Apache HttpClient tuning).
Welp! RestClient defaults does slightly better with 5 per-host connections.
👉 But that’s still tiny for production load.
If 100 users call your service simultaneously:
- Only 5 calls to downstream services can proceed
- 95 calls sit waiting in the connection queue
- Latency spikes
- Errors creep in
This is why things work locally but fail under load — you’re bottlenecked by the connection pool defaults.
🧪 Conceptual Benchmark
Scenario: 100 concurrent requests from OrderService → InventoryService
With Default RestClient (5 connections/host):
- Only 5 requests proceed at a time.
- 95 requests wait
- Latency grows
- Timeouts are likely under heavy load
Tuned RestClient (150 total, 50 per-host):
- Pool size reflects expected traffic — e.g., ~150 active users per second.
- Up to 50 concurrent requests per external service can proceed in parallel.
- Remaining requests queue briefly, but overall throughput remains smooth.
- Response times stay predictable and stable.
The point isn’t the exact number (150 or 50) — it’s about matching pool capacity to your app’s concurrency profile.
Your pool size should be proportional to peak concurrent users and downstream service latency.
✅ The Solution
Add Dependency
Apache HttpClient 5 dependency in the project, maven example:
<dependencies>
<!-- Apache HttpClient 5 (for advanced pooling, timeouts, etc.) -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
</dependencies>
Code Update
Configure your own HTTP connection pool
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
public class RestClientConfig {
public RestClient restClient() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(150); // total connections
connectionManager.setDefaultMaxPerRoute(50); // per-host connections
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.build();
return RestClient.builder()
.requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
.build();
}
}
⚙️ Bonus: Don’t Forget the Timeouts
Even with connection pooling tuned, your app can still hang if remote services become slow.
That’s where timeouts come in — they protect threads from waiting indefinitely.
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
public class RestClientConfig {
public RestClient restClient() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(150);
connectionManager.setDefaultMaxPerRoute(50);
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(2000) // time to establish connection (ms)
.setResponseTimeout(3000) // time waiting for server response (ms)
.setConnectionRequestTimeout(1000) // time to wait for connection from pool (ms)
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig)
.build();
return RestClient.builder()
.requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
.build();
}
}
💡 Quick Notes
- Timeouts above are in milliseconds (2000 ms = 2 seconds)
- connectTimeout: protects you from unresponsive hosts.
- connectionRequestTimeout: stops threads from waiting too long for a pooled connection.
- responseTimeout: prevents hanging on slow downstream responses.
Combined with a tuned connection pool, these timeouts make your RestClient fast, safe, and production-hardened.
Conclusion - Key Takeaways
- RestTemplate default = 2 global connections.
- RestClient default = 5 per-host connections.
- Configured pool = production-safe scaling.
⚠️ Never trust defaults for HTTP clients. They’re tuned for safety in local/dev, not performance in production.
TL;DR
- RestClient (Spring Boot 3 / Spring 6) is better than RestTemplate, but still not production-ready out of the box.
- Default pool size = 5 per host → easily becomes a bottleneck under load.
- Always configure:
- ✅ Connection pool size (maxTotal, maxPerRoute)
- ✅ Timeouts (connect, request, read)
- Keep your RestClient tuned, and it’ll handle thousands of concurrent requests gracefully.
💡 In short:
Spring’s RestClient is powerful — but like any engine, it needs tuning before you take it to production.
If you have reached here, then I have made a satisfactory effort to keep you reading. Please be kind enough to leave any comments or share with corrections.
My Other Blogs:
- When Resilience Backfires: Retry and Circuit Breaker in Spring Boot
- Setup GraphQL Mock Server
- Supercharge Your E2E Tests with Playwright and Cucumber Integration
- When Should You Use Server-Side Rendering (SSR)?
- Cracking Software Engineering Interviews
- Micro-Frontend Decision Framework
- Test SOAP Web Service using Postman Tool
Top comments (0)