Guide

Spring Boot and Postgres: where the limits actually are

A Spring Boot app spends its life in front of a Postgres instance, and the two articles here work on the same question from opposite ends: what runs out first, and where is the line you are not allowed to cross. One measures where a request actually queues once virtual threads are on. The other pushes search work into Postgres until the JPA Criteria API runs out of ways to express it.

Both ship with a companion repo that runs with one command, and every number in them comes out of that repo rather than someone else's benchmark.

Key ideas

  • Cheap threads relocate your limit, they don't remove it. spring.threads.virtual.enabled=true makes server.tomcat.threads.max inert and moves the concurrency gate to server.tomcat.max-connections, default 8192. Throughput barely changes. The queue does: it leaves the connector and reappears inside the app, where it fails late instead of harmlessly.
  • The scarce resource is usually the pool. In one measured run about 7,000 concurrent requests piled onto a 10-connection HikariCP pool and roughly 15% failed on the 30-second connection timeout, with zero pinning events in the flight recording. A deliberate @ConcurrencyLimit(limit = 10, policy = REJECT) turns those slow failures into fast 503s.
  • Postgres already does three kinds of search. Exact terms through a stored tsvector, typos through pg_trgm trigrams, and the vocabulary gap through a native synonym dictionary, all over one table with nothing to keep in sync.
  • The Criteria API reaches further than it looks, then stops. Hibernate renders an unregistered cb.function call straight through to Postgres, so ts_rank and its neighbours need no setup; only the @@ and % operators need a FunctionContributor, because an operator has no function-call form to fall through to. Fusing the three retrievers with Reciprocal Rank Fusion needs CTEs, ROW_NUMBER() and a FULL OUTER JOIN, so that one is native SQL, written in one visible place.
  • Every layer stops somewhere specific. A connector ceiling caps the count, not the pileup. ts_rank scores a row from that row alone, with no corpus-wide statistics and therefore no inverse-document-frequency term, and Reciprocal Rank Fusion is rank arithmetic that knows nothing about relevance beyond position. Knowing where each one ends is what tells you whether the next problem needs a limit, a query, or a different system.

In this guide

  1. Virtual Threads in Spring Boot: Where Your Real Limit Lives: Spring Boot virtual threads move Tomcat's concurrency gate from 200 to 8192 without telling you. Where your real limit lives, measured, plus the fix.
  2. Search that finds typos, synonyms and exact terms: one Postgres table, one Spring Boot app: Built in plain PostgreSQL 18 first, then driven from Spring Boot 4.1 through the JPA Criteria API, up to the exact line where Criteria runs out and one native query takes over. Runnable repo, 36 tests against real Postgres.

Frequently asked questions

Do virtual threads remove the need for a concurrency limit in Spring Boot?

No. Setting spring.threads.virtual.enabled=true makes server.tomcat.threads.max inert and moves the gate to server.tomcat.max-connections, which defaults to 8192. Throughput stays roughly the same, but requests that used to queue at the connector now queue inside the app and fail late on whatever is actually scarce, usually the connection pool. The fix is a limit you chose, such as @ConcurrencyLimit(limit = N, policy = REJECT).

Can Postgres do full-text search without a separate search engine?

For exact terms, typos and synonyms, yes. A stored tsvector handles lexical matching, pg_trgm trigrams reach misspellings, and a native synonym dictionary bridges vocabulary, all over one table with nothing to keep in sync. What it is not: ts_rank keeps no corpus-wide statistics, so there is no inverse-document-frequency term, and there is no reranker in the database. Semantic search is a separate leg, and that one needs pgvector.

What runs out first in a Spring Boot app under load, threads or database connections?

Usually the database connections. Threads are the cheap resource once virtual threads are on; the pool is not. In a measured run, roughly 7,000 concurrent requests piled onto a 10-connection HikariCP pool and about 15% of them failed on the 30-second connection timeout, with zero pinning events recorded. Whichever layer is scarcest is the one that should carry the deliberate limit.