pgjdbc and HikariCP on Supabase connection paths
A Java service that reaches Supabase Postgres through pgjdbc and HikariCP has four connection paths to choose from, and pgjdbc’s defaults behave differently on each. This reference covers what a pooled JDBC workload saw on each path and the settings each path needs, for anyone moving an API layer onto JDBC or sizing its pools.
The measurements come from module PC08 of the
pooler-checkout experiment
in supabase-lab: three runs
on 2026-11-04 against one Small project (max_connections 90, Postgres
17.11) on a Pro organization in ap-northeast-1, with the IPv4 add-on on,
from an IPv4-only client in Singapore.1 The client was one JVM
(eclipse-temurin 21.0.13 in Docker) running org.postgresql:postgresql
42.7.14 and com.zaxxer:HikariCP 7.1.0. An “app instance” below is one
HikariDataSource in that JVM, so four instances are four pools in one
process, not four hosts. In each iteration a worker borrowed a connection
per operation for a parameterised point SELECT, an UPDATE ... RETURNING
in an explicit transaction and a 5-row batch INSERT in an explicit
transaction, and checked every result against the row id it asked for.
Every latency includes the Singapore-Tokyo round trip, about five round
trips per iteration. Anything not marked measured is cited to the docs.
- Supavisor transaction mode (port 6543 on the pooler host) needs
prepareThreshold=0. With pgjdbc’s default threshold of 5, 603 of 4522 finished iterations ran without an error at 12 connections and 516 of 11572 at 40 (run 11); withprepareThreshold=0the same pooler, project and load ran 6000 of 6000 and 20000 of 20000, in both runs that tried it. The Supabase docs give the same setting for JDBC.2 - The default threshold also returned results for the wrong row with no
error. In run 12 an
UPDATE ... RETURNINGsent for id 4673 came back with row 367, aSELECTfor id 8774 with row 6202, and three 5-row batchINSERTs reported[0, 0, 0, 0, 0], each without an exception. NoprepareThreshold=0row had one. - Direct 5432, Supavisor session mode and the dedicated PgBouncer ran clean
with pgjdbc’s defaults. The dedicated pooler did so while 5262 of 6000
SELECTs came back from a different backend than the connection’s previous one. - pgjdbc’s
socketTimeoutdefaults to 0, no read timeout.3 Left there on Supavisor transaction mode with the default threshold, 11 of 12 workers had been inside anUPDATEfor 180584 to 243074 ms when a 240 s cap (plus a 5 s join grace) stopped the cell. - The session pool was 15. A Hikari pool larger than that filled to 15 and
kept retrying the rest in the background; a worker learned of it only
after waiting
connectionTimeout(30 s), withEMAXCONNSESSIONas the cause. - Both transaction pools queue a transaction that finds no free backend: 40 connections running 1 s transactions were all served on each. A transaction still queued after 60 s on Supavisor or 80 s on the dedicated pooler ended FATAL and the connection was closed.
Which path, and what it needs
Section titled “Which path, and what it needs”| Path | Endpoint | pgjdbc server-side prepare | Pool ceiling (Small) | When the pool is full | From an IPv4-only network |
|---|---|---|---|---|---|
| Direct | db.<ref>.supabase.co:5432 | default works | max_connections 90 (read back, not filled) | not run | needs the IPv4 add-on |
| Supavisor session | pooler host, port 5432 | default works | 15 clients | the 16th is refused at connect with EMAXCONNSESSION | reachable |
| Supavisor transaction | pooler host, port 6543 | prepareThreshold=0 or preferQueryMode=simple | 16 active backends | queues; FATAL ECHECKOUTTIMEOUT after 60 s | reachable |
| Dedicated PgBouncer | db.<ref>.supabase.co:6543 | default works | 15 to 16 active backends | queues; FATAL query_wait_timeout after 80 s | needs the IPv4 add-on |
The docs give 15 as the default pool size on Micro, Small and Medium and 20
on Large and XL,4 and say the dedicated pooler supports prepared
statements on PgBouncer 1.21 and later and is reachable over IPv6, or over
IPv4 once the project has the add-on.5 From an IPv4-only client without the add-on,
direct 5432 answered ENOTFOUND in 6 of 6 trials (PC02). The pool ceilings
in the table are active pg_sleep backends counted through the Management
API, and the EMAXCONNSESSION text, which names pool_size: 15. The
transaction pool held 16 against the documented 15; PC01b counted the same
16 on Micro.
Named server-side statements on each path
Section titled “Named server-side statements on each path”pgjdbc switches a PreparedStatement to a named server-side statement on
its fifth execution by default (prepareThreshold 5; the loaded driver
reported 5).3 The name exists on the backend that parsed it.
Direct and session mode keep one backend per connection for its life, so
the name is there on the next execution. Transaction mode hands each
transaction whichever backend is free: at 12 connections with the default
threshold, 2914 of 4234 SELECTs that returned the requested row came back
from a different pg_backend_pid() than the same connection’s previous one.
Run 11, 500 iterations per worker. Shape M is 4 instances x
maximumPoolSize 3 with 3 workers each (12 connections); shape H is 4 x 10
with 10 workers each (40 connections, Hikari’s default pool size per
instance). Before every cell except one the module terminated the
Supavisor and pc08-probe backends through the Management API and waited
3 s, so one cell’s named statements did not carry into the next.
| Path | Arm | Shape | Iterations ok / attempted | Wrong results | Backend switches / SELECTs with the requested row |
|---|---|---|---|---|---|
| Direct 5432 | default (5) | M | 6000 / 6000 | 0 | 0 / 6000 |
| Direct 5432 | default (5) | H | 20000 / 20000 | 0 | 0 / 20000 |
| Supavisor session | default (5) | M | 6000 / 6000 | 0 | 0 / 6000 |
| Supavisor transaction | default (5) | M | 603 / 4522 | 0 | 2914 / 4234 |
| Supavisor transaction | default (5), previous cell’s backends kept | M | 566 / 4562 | 1 | 2864 / 4243 |
| Supavisor transaction | default (5) | H | 516 / 11572 | 14 | 8795 / 10664 |
| Supavisor transaction | extendedCacheEverything | M | 570 / 4830 | 1 | 3150 / 4548 |
| Supavisor transaction | prepareThreshold=0 | M | 6000 / 6000 | 0 | 5228 / 6000 |
| Supavisor transaction | prepareThreshold=0 | H | 20000 / 20000 | 0 | 16601 / 20000 |
| Supavisor transaction | preferQueryMode=simple | M | 6000 / 6000 | 0 | 5261 / 6000 |
| Dedicated PgBouncer | default (5) | M | 6000 / 6000 | 0 | 5262 / 6000 |
| Dedicated PgBouncer | default (5) | H | 20000 / 20000 | 0 | 18601 / 20000 |
| Dedicated PgBouncer | extendedCacheEverything | M | 6000 / 6000 | 0 | 5213 / 6000 |
The prepareThreshold=0 arm was clean on direct (M and H), session (M) and
the dedicated pooler (M and H) as well. Run 12 repeated the four Supavisor
transaction-mode cells of shapes M and H with a 240 s cap: the default
threshold ran 276 of 2253 (M) and 283 of 4356 (H) without an error, and
prepareThreshold=0 6000 of 6000 and 20000 of 20000.
What the failing cell saw at 12 connections (run 11, default threshold), the most frequent errors grouped by SQLSTATE:
26000 prepared statement "S_n" does not exist: 2027 batchINSERTs and 938UPDATEs, plus 219 batchINSERTs reported asBatchUpdateException [26000].42P05 prepared statement "S_n" already exists: 320 batchINSERTs asBatchUpdateExceptionand 90 asPSQLException, 243UPDATEs, 134SELECTs.25P02 current transaction is aborted, commands ignored until end of transaction block: 143SELECTs (autocommit statements), 143UPDATEs, 125 batchINSERTs; pgjdbc attached the earlier26000or42P05as the cause.08P01 bind message supplies 0 parameters, but prepared statement "S_9" requires 1and the same shape with other counts: 38UPDATEs, 48 batchINSERTs, 13SELECTs.08006 An I/O error occurred while sending to the backend, caused bySocketTimeoutException: Read timed out: 102UPDATEs after 30150.16 to 30209.44 ms (the 30 ssocketTimeout).
Postgres logged 3835 does not exist, 1333 already exists, 423
current transaction is aborted, 163 bind message supplies N parameters
and 52 there is already a transaction in progress lines for that window.
Hikari replaced the connections it marked broken: 276 physical connects in
the cell against 12 in the clean M rows. How Supavisor forwards a named
Parse and Bind was not traced; the errors say a named statement was
missing, already present, or held different parameters on the backend the
statement reached.
Prisma showed the same split on a Medium project: 7 of 500 iterations ok on
Supavisor transaction mode without pgbouncer=true at 20 concurrent
clients, 500 of 500 on the dedicated pooler with or without it
(medium-serverless MS10). A single client’s SQL PREPARE/EXECUTE
succeeded on the dedicated PgBouncer on 6543, reached through a PrivateLink
endpoint (privatelink-aws T11). It did not touch Supavisor, and it says
nothing about concurrent clients or about pgjdbc’s protocol-level named
statements.
Results for the wrong row, with no error
Section titled “Results for the wrong row, with no error”Run 12 recorded the text of each result that failed its check in the 40-connection cell with the default threshold. Times are from the start of the workers:
2565ms update id 4673: got (id=367, tag=upd)3072ms update id 2707: got (id=5130, tag=upd)3895ms select id 8774: got (id=6202, k=1, v=i2w2n393, tag=sel, pid=17829)2767ms,3562msand3971ms insert batch: counts [0, 0, 0, 0, 0]
Two readings fit, and the run cannot separate them: the statement ran with
another statement’s or another client’s parameters (for the first
UPDATE, row 367 would then have been changed in place of 4673), or the
client received a reply that belonged to another statement. What reached
the database per statement was not logged. Run 11 counted 1, 14 and 1 such
results in the backends-kept, 40-connection and extendedCacheEverything cells
without recording their text. No prepareThreshold=0 row in either run had
one.
Reads that never return
Section titled “Reads that never return”pgjdbc’s socketTimeout is in seconds and 0 disables it.3 With
it left at 0 and the default threshold on Supavisor transaction mode (run
11), 11 of 12 workers were still inside the UPDATE of an explicit
transaction when the 240 s cap (plus a 5 s join grace; the summary came at
245006 ms) stopped the cell, the longest for 243074 ms and the shortest for
180584 ms. With socketTimeout 30 the same kind of
wait became a counted 08006 after 30150.16 to 30209.44 ms. In a smoke run
before that, a thread dump showed workers waiting in
QueryExecutorImpl.processResults while Postgres listed two probe backends,
both idle (the dump is not in an artifact). No prepareThreshold=0 row hit
the 30 s timeout.
Hikari pools against the session pool
Section titled “Hikari pools against the session pool”Hikari’s defaults as the loaded jar reported them: maximumPoolSize 10,
connectionTimeout 30000 ms, and minimumIdle unset, so each pool tries to
fill to maximumPoolSize. Session mode on this project allowed 15 clients.
| Shape | What happened (run 11) |
|---|---|
| 1 pool of 20, 20 workers, 100 iterations each | The pool held 15 connections. 22 of 37 physical connects failed in 297.46 to 471.93 ms with XX000 FATAL: (EMAXCONNSESSION) max clients reached in session mode - max clients are limited to pool_size: 15. 2000 of 2000 iterations ok, p99 4157.83 ms, max 10056.18 ms, while workers queued inside Hikari for the 15 connections. |
| 4 pools of 10, 10 workers each, 100 iterations each | The pools started with 6, 4, 3 and 2 connections. 192 of 207 physical connects failed with the same EMAXCONNSESSION. 3976 of 4000 iterations ok; 33 borrows timed out after 30000.38 to 30010.18 ms with SQLTransientConnectionException [XX000] ... request timed out after 30001ms, the EMAXCONNSESSION error as the cause. The smaller the pool, the more of its borrows timed out: 0, 4, 11 and 18 of the 33. |
1 pool of 20, each of 20 workers holding pg_sleep(40) once | 15 holds ran. 5 borrows failed after 30021.7 to 30021.99 ms with Connection is not available, request timed out after 30005ms, SQLSTATE XX000 from the last EMAXCONNSESSION. |
No pool failed to start: each got its first connection. A check that only opens one connection at startup would pass (reasoned, not measured). The refusal at connect matches what node-postgres saw in PC05a.
Forty connections against the transaction pools
Section titled “Forty connections against the transaction pools”Four pools of 10, each worker on its own connection, so Hikari did no waiting; the wait was inside the pooler.
| Path | Hold per worker | Outcome (run 11) |
|---|---|---|
| Supavisor transaction | 10 x pg_sleep(1) | 400 of 400 served, p50 2057.94 ms, max 3045.7 ms; 16 active sleepers at +5 s |
| Dedicated PgBouncer | 10 x pg_sleep(1) | 400 of 400 served, p50 2944.92 ms, max 3141.39 ms; 15 active sleepers at +5 s |
| Supavisor transaction | one pg_sleep(45) | 32 of 40 served; 8 failed 60075.2 to 60085.68 ms after the statement was sent, with XX000 FATAL: (ECHECKOUTTIMEOUT) unable to check out connection from the pool after 60000ms in Transaction mode; 2 of 40 follow-up select 1 failed on replacement connects that did not complete in 30 s |
| Dedicated PgBouncer | one pg_sleep(45) | 32 of 40 served; 8 failed after 80337.73 to 80344.87 ms with 08P01 FATAL: query_wait_timeout (the config reads back query_wait_timeout 80, in seconds); all 40 follow-ups ok |
Both errors are FATAL, so the server closes pgjdbc’s connection and Hikari
replaces it. Hikari’s connectionTimeout plays no part in the queued
statements: the connection was already open. It did end the 2 failed
follow-up borrows after the Supavisor wait, on replacement connects. The 60 s
Supavisor wait (PC01, PC05) and the separate 15 s one (PC06) are measured
with node-postgres in
Supabase incidents: what a client can do.
Reading the numbers
Section titled “Reading the numbers”- One Small project, one region, one client vantage, one JVM. Separate hosts per app instance were not run.
- An iteration is ok when nothing in it threw, so an ok iteration can hold
a wrong result. Attempted counts finished iterations; workers still
inside an operation at the cap are not in it. Cells with errors ran into
the 480 s cap (240 s for the
socketTimeout0 cell in run 11 and for every cell in run 12), so their attempted counts are smaller. - Backend switches count
SELECTs that came back from a differentpg_backend_pid()than the same connection’s previousSELECT, over theSELECTs that returned the requested row. - n: run 11 ran each row once; the four Supavisor transaction-mode cells of shapes M and H ran twice (runs 11 and 12). The session-pool and 40-connection rows ran once.
- Not run: fewer than 12 or more than 40 connections on the transaction
poolers,
prepareThresholdvalues other than 0 and 5,autosave,reWriteBatchedInserts, other pgjdbc or HikariCP versions, other compute sizes and regions, an in-region client. Why named statements fail through Supavisor transaction mode was not traced: only client errors and Postgres’s log were observed.
Decision guide
Section titled “Decision guide”- If the client network reaches IPv6 or the project has the IPv4 add-on,
and the sum of
maximumPoolSizeacross instances fits undermax_connections: direct 5432 with pgjdbc’s defaults. - Same network, more connections than that: the dedicated PgBouncer on
db.<ref>.supabase.co:6543, with pgjdbc’s defaults (measured clean at 12 and 40 connections only, belowmax_connections90). - IPv4-only without the add-on, and the sum of
maximumPoolSizeat or under the session pool (15 on Small): Supavisor session mode on 5432, with pgjdbc’s defaults. - IPv4-only without the add-on, and more connections than that: Supavisor
transaction mode on 6543 with
prepareThreshold=0and asocketTimeout.
The ordering is a design choice built on the rows above; the
max_connections budget also has to leave room for the platform’s own
services, which this experiment did not measure.
What to do about it
Section titled “What to do about it”| Practice | Evidence | Module |
|---|---|---|
Set prepareThreshold=0 on every JDBC URL that reaches Supavisor on port 6543. | The default (5) ran 603 of 4522 (12 connections) and 516 of 11572 (40 connections) iterations without an error in run 11, and returned 14 results for the wrong row at 40 connections; prepareThreshold=0 ran 6000 of 6000 and 20000 of 20000 in runs 11 and 12. The docs give the same setting.2 | PC08 |
Do not use preferQueryMode=extendedCacheEverything on Supavisor transaction mode. | 570 of 4830 iterations ok and 1 wrong result; simple ran 6000 of 6000. | PC08 |
Set socketTimeout above your longest statement plus the 60 s checkout wait. | With the default 0, 11 of 12 workers waited 180584 to 243074 ms for a reply until a 240 s cap (plus a 5 s join grace) stopped them; 30 s turned the same wait into an 08006. The value is a design choice. | PC08 |
Keep the sum of maximumPoolSize across instances at or under the session pool. | 15 on Small: excess connects got EMAXCONNSESSION, and workers saw it only after the 30 s connectionTimeout; 4 pools of 10 split the 15 as 6/4/3/2. | PC08, PC05 |
| Size transaction-mode connections by how long each transaction holds a backend. | 40 connections running 1 s transactions were all served, on 16 active backends (Supavisor) and 15 (dedicated, sampled at +5 s); a transaction that cannot start within 60 s (Supavisor) or 80 s (dedicated) ends FATAL. Sizing by hold time is a design choice. | PC08, PC01 |
Retry XX000 ECHECKOUTTIMEOUT and 08P01 query_wait_timeout on a new connection. | Both are FATAL, so the server closes the connection and Hikari replaces it. After the Supavisor wait 2 of 40 follow-up select 1 failed on replacement connects that did not complete in 30 s; after the dedicated wait all 40 were ok (n=1). Treating them as retryable is a design choice. | PC08 |
| Use the dedicated PgBouncer for named statements on a transaction pooler. | pgjdbc’s defaults ran clean at 12 and 40 connections while switching backends on 5262 of 6000 and 18601 of 20000 SELECTs; Supavisor transaction mode with prepareThreshold=0 also ran clean. Preferring the dedicated pooler is a design choice: no latency benefit of named statements was measured from this off-region client. The docs give PgBouncer 1.21 and later.5 | PC08 |
| Turn on the IPv4 add-on before an IPv4-only network uses direct or the dedicated pooler. | Direct 5432 answered ENOTFOUND without it (6 of 6). With it switched on, an A record came after 10 s and the first direct connect 12 s after the PATCH in run 10 (10 s poll step), against an IPv4 address after 80 s and a direct connect at 81 s in both PC02 runs (5 s poll step). | PC02, PC08 |
Evidence
Section titled “Evidence”| Claim | Status | How it was checked |
|---|---|---|
Default threshold on Supavisor transaction mode: 603 / 4522 (M) and 516 / 11572 (H) ok in run 11, 276 / 2253 and 283 / 4356 in run 12; prepareThreshold=0 6000 / 6000 and 20000 / 20000 in both | measured, 2026-11-04, n=2 | PC08 - four Hikari pools in one JVM, 500 iterations per worker, every result checked against the requested id; run 11, run 12 |
| Six results for the wrong row or count, with no error, at 40 connections with the default threshold | measured, run 12, n=1 | PC08 wrong_examples; run 12 |
Direct, session and the dedicated pooler clean with pgjdbc’s defaults; the dedicated pooler switching backends on 5262 of 6000 SELECTs | measured, run 11, n=1 | run 11 |
socketTimeout 0: 11 of 12 workers inside an UPDATE for 180584 to 243074 ms at the cap | measured, run 11, n=1 | run 11 |
Session pool 15; EMAXCONNSESSION at fill; 33 borrow timeouts at 30 s across 4 pools of 10 | measured, run 11, n=1 | run 11 |
| 40 connections queue on both transaction pools; FATAL after 60 s (Supavisor) and 80 s (dedicated) | measured, run 11, n=1 | run 11 |
| IPv4 add-on: first direct connect 12 s after the PATCH | measured, run 10, n=1 | run 10 |
JDBC prepareThreshold=0 on the shared pooler in transaction mode; prepared statements on the dedicated pooler from PgBouncer 1.21; default pool size 15 on Small | documented | the Connecting to Postgres and Compute and Disk pages, read 2026-11-04 |
pgjdbc prepareThreshold default 5 and socketTimeout default 0 | documented; the loaded driver reported 5 | the pgJDBC “Initializing the Driver” page (connection parameters), read 2026-11-04; PC08 PC08-env |
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| MS10 | medium-serverless | ms10-prisma6-paths.ts | out/2026-09-30 |
| PC01 | pooler-checkout | pc01-checkout-timeout.ts | run-2026-10-10T01-53-22-718Z, run-2026-10-10T02-40-11-359Z |
| PC02 | pooler-checkout | pc02-fallback.ts | run-2026-10-10T02-40-11-359Z, run-2026-10-10T03-09-14-920Z |
| PC05 | pooler-checkout | pc05-checkout-matrix.ts | run-2026-10-23T08-11-43-542Z, run-2026-10-23T08-11-48-633Z |
| PC06 | pooler-checkout | pc06-login-delay.ts | run-2026-10-23T08-11-43-542Z, run-2026-10-23T08-11-48-633Z |
| PC08 | pooler-checkout | pc08-pgjdbc-hikari.ts | run-2026-11-04T09-11-22-458Z, run-2026-11-04T09-24-08-535Z, run-2026-11-04T11-18-37-198Z |
| T11 | privatelink-aws | RUNLOG | - |
Related docs
Section titled “Related docs”- Supabase incidents: what a client can do - the 60 s and 15 s pooler checkout waits, the fallback to session mode, and driver pools after a reset, measured with node-postgres, postgres.js and Prisma (PC01 to PC07).
- AWS PrivateLink to Supabase - the single-client prepared-statement probe (T11) and Prisma at 20 clients (MS10).
- Supabase compute and disk sizing -
max_connectionsand pooler client limits per compute size. - Supabase resilience runbook - reading an
ECHECKOUTTIMEOUTin production.
References
Section titled “References”-
supabase-lab, “Pooler-checkout experiment,” supabase-lab runlog, section “PC08 - pgjdbc + HikariCP on each path, server-side prepare on and off (runs 10 to 12, 2026-11-04)”. https://github.com/erfianugrah/supabase-lab/blob/aba0ed1/experiments/pooler-checkout/RUNLOG.md ↩
-
Supabase, “Connecting to Postgres - Transaction mode limitations,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres#transaction-mode-limitations ↩ ↩2
-
pgJDBC, “Initializing the Driver,” jdbc.postgresql.org. https://jdbc.postgresql.org/documentation/use/ ↩ ↩2 ↩3
-
Supabase, “Compute and Disk,” Supabase Docs. https://supabase.com/docs/guides/platform/compute-and-disk ↩
-
Supabase, “Connecting to Postgres - Dedicated pooler,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres#dedicated-pooler ↩ ↩2