Spring Boot Saturation Test: Why 300 Users Caused 42% Failures

The API did not fail at 100 users.

It did not even fail cleanly at 200 users.

At 300 concurrent users, the system crossed a line. Latency stopped behaving like normal load-test noise and the error rate jumped to 42.93%.

The application was not doing anything exotic. It was a Spring Boot service talking to PostgreSQL through HikariCP, running with mostly default-style tuning.

The failure was not caused by Spring Boot being slow. It was caused by allowing more request concurrency than the database connection pool could actually serve.

This is the kind of performance bug that hides in plain sight. The application looks healthy at moderate load, then collapses quickly once one internal queue becomes saturated.


The Test

The project was a saturation lab for a Spring Boot API backed by PostgreSQL.

The goal was not to prove a framework benchmark. The goal was to answer a production-style question:

What happens when request concurrency grows faster than the database connection pool can absorb?

The environment was intentionally ordinary:

  • Spring Boot application
  • PostgreSQL database
  • HikariCP with maximumPoolSize=10
  • Embedded Tomcat with maxThreads=200
  • JMeter load test
  • 10-minute steady-state runs

The important configuration shape looked like this:

server:
  tomcat:
    threads:
      max: 200

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      connection-timeout: 30000

That mismatch is the whole story: the web layer could accept far more concurrent work than the database pool could execute at the same time.


The Load Pattern

The test increased concurrency in steps and watched how the system behaved before and after saturation.

ConcurrencyObserved BehaviorInterpretation
100 usersStableSystem still had enough capacity to absorb the request rate
200 usersLatency increasedContention became visible, but failures were still controlled
300 users42.93% HTTP 500Connection pool saturation became user-visible failure

The key signal was not only that latency increased. Latency is expected to increase under load. The key signal was the shape of the failure: a capacity cliff.

From 50 to 200 threads, latency grew proportionally. At 300 threads, the error rate spiked. That is the difference between normal contention and saturation.


The First Wrong Explanation

The first reaction to a 42% failure rate is often to blame the database.

That can be true, but it is too vague to be useful.

In this case, PostgreSQL was not the first thing to blame. The more precise question was: how many requests were allowed to wait for database connections, and how long were they allowed to wait?

The log pattern answered that question.

HikariPool-1 - Connection is not available,
request timed out after 30000ms
(total=10, active=10, idle=0, waiting=170+)

That line is more useful than a generic HTTP 500 count. It tells us the application did not run out of request threads first. It ran out of database connections.

Every connection was active. No connection was idle. More requests were waiting. Eventually they hit the 30-second connection timeout.


What Was Really Happening

At 300 users, the application had too many request threads competing for too few database connections.

300 Concurrent Requests
            |
            v
+-----------------------+
|     Tomcat Threads    |
|        (200)          |
+-----------------------+
            |
            v
+-----------------------+
|   Hikari Connections  |
|         (10)          |
+-----------------------+
            |
            v
       PostgreSQL
When request threads greatly exceed available DB connections, connection pool starvation occurs.

Tomcat could keep accepting and scheduling work. HikariCP could only let 10 database operations run concurrently. The remaining requests did not disappear. They waited.

That wait is where the system started losing control.

  • A request entered Tomcat.
  • The request reached code that needed a database connection.
  • All 10 Hikari connections were already active.
  • The request waited inside the connection pool.
  • More request threads reached the same point and joined the wait.
  • After 30 seconds, Hikari timed out.
  • The application returned HTTP 500.

This is not a gradual performance problem. It is a queueing problem.


Why 200 Users Looked Different From 300

At 200 users, the system was already under pressure. Latency increased because requests were starting to queue behind scarce database connections.

But the queue was still mostly survivable. Enough requests obtained connections before hitting the timeout.

At 300 users, the arrival rate exceeded what the pool could drain. Once that happens, queue length does not merely grow a little. It grows until timeouts begin to dominate the result.

Saturation is the point where waiting becomes failure.

That is why the chart matters. A linear-looking latency increase before the cliff can trick you into thinking the system still has headroom. The error spike shows that the hidden queue has already become the bottleneck.


Root Cause

The root cause was connection pool starvation caused by a concurrency mismatch.

LayerCapacity SignalWhat It Meant
Tomcat~200 request threadsThe application could accept many concurrent requests
HikariCP10 database connectionsOnly 10 DB operations could execute concurrently
PostgreSQLFinite query throughputRequests beyond DB capacity had to wait
Client300 concurrent usersLoad exceeded the application’s effective DB-backed capacity

The important lesson is that the smallest constrained resource often defines the real capacity of the endpoint.

In this system, the web tier advertised more concurrency than the database tier could support.


Why Defaults Are Dangerous Here

Default settings are useful for getting an application running. They are not a capacity plan.

A default Tomcat thread count and a small Hikari pool can be perfectly reasonable for development and low traffic. Under load, the mismatch creates a misleading shape: the service accepts work faster than it can complete the database portion of that work.

That is worse than rejecting early because it creates long waits, wasted request time, and noisy failures.

The system did not apply adaptive back-pressure. It accepted too much work, queued internally, and failed late.


The Back-Pressure Failure Pattern

The behavior model looked like this:

  • Connection pool saturates
  • Waiting queue grows
  • Request latency rises
  • Connection timeout is reached
  • HTTP 500 responses increase
  • Clients retry or users repeat requests
  • The system receives even more pressure

That final step is where production incidents become ugly. Load tests usually stop at measuring the first failure. Real clients often retry, refresh, or trigger upstream retries. That turns saturation into amplification.


What I Would Check In Production

If this happened in a real service, I would not start by increasing every pool size.

I would first confirm the bottleneck with runtime metrics:

  • Hikari active connections
  • Hikari idle connections
  • Hikari pending threads
  • Connection acquisition time
  • Tomcat busy threads
  • HTTP 500 count by exception type
  • Database query duration
  • PostgreSQL CPU, locks, and wait events

Those metrics separate three different problems that can look similar from the outside: too few connections, slow queries, and too many concurrent request threads.

The Hikari log pointed strongly toward connection acquisition timeout, but the next step would be proving whether the pool size was too small, the queries were too slow, or the endpoint allowed too much concurrent work for its downstream capacity.


How To Fix It

The fix is not simply “increase Hikari to 100.”

That might reduce connection waits inside the application while moving the bottleneck into PostgreSQL. A bigger pool can make the database slower if it creates too much concurrent query execution.

A safer fix has several parts.

1. Align Concurrency With Database Capacity

The application should not allow 200 request threads to pile onto a pool of 10 database connections without a deliberate reason.

Either the request concurrency must be controlled, the pool must be sized based on measured DB capacity, or the endpoint must be redesigned to reduce DB time per request.

2. Measure Connection Acquisition Time

Most teams watch query time. Fewer teams watch how long requests wait before they even get a connection.

Connection acquisition time is the early warning signal for this class of incident.

3. Fail Earlier And More Clearly

A 30-second wait before failure is painful. It consumes resources and gives users a slow error.

For some APIs, a shorter timeout with a controlled 503 response is better than a long wait ending in a generic 500.

4. Add Back-Pressure

Back-pressure can be implemented at several layers: API gateway limits, servlet thread limits, bulkheads, rate limits, queue limits, or endpoint-specific concurrency guards.

The goal is not to reject traffic casually. The goal is to stop overload from turning into a collapse.

5. Reduce Database Hold Time

If each request holds a connection for too long, pool size becomes a symptom rather than the root issue.

Query tuning, pagination, transaction boundary cleanup, and removing unnecessary database calls can increase effective capacity without increasing the pool.


Engineering Lessons

  • The effective capacity of a Spring Boot endpoint is often defined by the smallest downstream pool, not by Tomcat threads.
  • A rising P95 latency curve can be the warning before a failure cliff.
  • Connection pool starvation often appears as HTTP 500s, but the real symptom is waiting for a connection.
  • Increasing pool size without measuring database capacity can move the bottleneck instead of fixing it.
  • Controlled saturation is better than late failure after long internal queues.

Conclusion

The 300-user test failed because the system accepted more concurrent work than its database connection pool could serve.

Spring Boot was not the villain. PostgreSQL was not automatically the villain either. The failure came from a mismatch between request concurrency and database-backed capacity.

At 100 users, the system looked stable. At 200 users, it started showing pressure. At 300 users, the hidden queue became visible as a 42.93% failure rate.

That is what saturation looks like in production: not a polite slowdown, but a cliff.

Measure the pool. Watch the waiters. Design the failure mode before traffic designs it for you.