Skip to content

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); with prepareThreshold=0 the 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 ... RETURNING sent for id 4673 came back with row 367, a SELECT for id 8774 with row 6202, and three 5-row batch INSERTs reported [0, 0, 0, 0, 0], each without an exception. No prepareThreshold=0 row 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 socketTimeout defaults to 0, no read timeout.3 Left there on Supavisor transaction mode with the default threshold, 11 of 12 workers had been inside an UPDATE for 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), with EMAXCONNSESSION as 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.
JVMHikariCP pools + pgjdbcdirectdb host :5432Supavisor sessionpooler host :5432Supavisor transactionpooler host :6543dedicated PgBouncerdb host :6543Postgresmax_connections 90 on Smallone backend per connection15 clients, backend kept16 backends, per transaction15 to 16 backends, per transaction
PathEndpointpgjdbc server-side preparePool ceiling (Small)When the pool is fullFrom an IPv4-only network
Directdb.<ref>.supabase.co:5432default worksmax_connections 90 (read back, not filled)not runneeds the IPv4 add-on
Supavisor sessionpooler host, port 5432default works15 clientsthe 16th is refused at connect with EMAXCONNSESSIONreachable
Supavisor transactionpooler host, port 6543prepareThreshold=0 or preferQueryMode=simple16 active backendsqueues; FATAL ECHECKOUTTIMEOUT after 60 sreachable
Dedicated PgBouncerdb.<ref>.supabase.co:6543default works15 to 16 active backendsqueues; FATAL query_wait_timeout after 80 sneeds 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.

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.

PathArmShapeIterations ok / attemptedWrong resultsBackend switches / SELECTs with the requested row
Direct 5432default (5)M6000 / 600000 / 6000
Direct 5432default (5)H20000 / 2000000 / 20000
Supavisor sessiondefault (5)M6000 / 600000 / 6000
Supavisor transactiondefault (5)M603 / 452202914 / 4234
Supavisor transactiondefault (5), previous cell’s backends keptM566 / 456212864 / 4243
Supavisor transactiondefault (5)H516 / 11572148795 / 10664
Supavisor transactionextendedCacheEverythingM570 / 483013150 / 4548
Supavisor transactionprepareThreshold=0M6000 / 600005228 / 6000
Supavisor transactionprepareThreshold=0H20000 / 20000016601 / 20000
Supavisor transactionpreferQueryMode=simpleM6000 / 600005261 / 6000
Dedicated PgBouncerdefault (5)M6000 / 600005262 / 6000
Dedicated PgBouncerdefault (5)H20000 / 20000018601 / 20000
Dedicated PgBouncerextendedCacheEverythingM6000 / 600005213 / 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 batch INSERTs and 938 UPDATEs, plus 219 batch INSERTs reported as BatchUpdateException [26000].
  • 42P05 prepared statement "S_n" already exists: 320 batch INSERTs as BatchUpdateException and 90 as PSQLException, 243 UPDATEs, 134 SELECTs.
  • 25P02 current transaction is aborted, commands ignored until end of transaction block: 143 SELECTs (autocommit statements), 143 UPDATEs, 125 batch INSERTs; pgjdbc attached the earlier 26000 or 42P05 as the cause.
  • 08P01 bind message supplies 0 parameters, but prepared statement "S_9" requires 1 and the same shape with other counts: 38 UPDATEs, 48 batch INSERTs, 13 SELECTs.
  • 08006 An I/O error occurred while sending to the backend, caused by SocketTimeoutException: Read timed out: 102 UPDATEs after 30150.16 to 30209.44 ms (the 30 s socketTimeout).

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.

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, 3562ms and 3971ms 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.

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’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.

ShapeWhat happened (run 11)
1 pool of 20, 20 workers, 100 iterations eachThe 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 eachThe 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) once15 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.

PathHold per workerOutcome (run 11)
Supavisor transaction10 x pg_sleep(1)400 of 400 served, p50 2057.94 ms, max 3045.7 ms; 16 active sleepers at +5 s
Dedicated PgBouncer10 x pg_sleep(1)400 of 400 served, p50 2944.92 ms, max 3141.39 ms; 15 active sleepers at +5 s
Supavisor transactionone 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 PgBouncerone 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.

  • 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 socketTimeout 0 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 different pg_backend_pid() than the same connection’s previous SELECT, over the SELECTs 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, prepareThreshold values 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.
JDBC app on SupabaseIPv6 reachable,or IPv4 add-on on?total connections fitmax_connections?yestotal connections at orunder the session pool?nodirect 5432pgjdbc defaultsyesdedicated PgBouncer 6543pgjdbc defaultsnoSupavisor session 5432pgjdbc defaultsyesSupavisor transaction 6543prepareThreshold=0, socketTimeoutno
  1. If the client network reaches IPv6 or the project has the IPv4 add-on, and the sum of maximumPoolSize across instances fits under max_connections: direct 5432 with pgjdbc’s defaults.
  2. 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, below max_connections 90).
  3. IPv4-only without the add-on, and the sum of maximumPoolSize at or under the session pool (15 on Small): Supavisor session mode on 5432, with pgjdbc’s defaults.
  4. IPv4-only without the add-on, and more connections than that: Supavisor transaction mode on 6543 with prepareThreshold=0 and a socketTimeout.

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.

PracticeEvidenceModule
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.2PC08
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.5PC08
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
ClaimStatusHow 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 bothmeasured, 2026-11-04, n=2PC08 - 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 thresholdmeasured, run 12, n=1PC08 wrong_examples; run 12
Direct, session and the dedicated pooler clean with pgjdbc’s defaults; the dedicated pooler switching backends on 5262 of 6000 SELECTsmeasured, run 11, n=1run 11
socketTimeout 0: 11 of 12 workers inside an UPDATE for 180584 to 243074 ms at the capmeasured, run 11, n=1run 11
Session pool 15; EMAXCONNSESSION at fill; 33 borrow timeouts at 30 s across 4 pools of 10measured, run 11, n=1run 11
40 connections queue on both transaction pools; FATAL after 60 s (Supavisor) and 80 s (dedicated)measured, run 11, n=1run 11
IPv4 add-on: first direct connect 12 s after the PATCHmeasured, run 10, n=1run 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 Smalldocumentedthe Connecting to Postgres and Compute and Disk pages, read 2026-11-04
pgjdbc prepareThreshold default 5 and socketTimeout default 0documented; the loaded driver reported 5the pgJDBC “Initializing the Driver” page (connection parameters), read 2026-11-04; PC08 PC08-env
ModuleExperimentTestArtifact
MS10medium-serverlessms10-prisma6-paths.tsout/2026-09-30
PC01pooler-checkoutpc01-checkout-timeout.tsrun-2026-10-10T01-53-22-718Z, run-2026-10-10T02-40-11-359Z
PC02pooler-checkoutpc02-fallback.tsrun-2026-10-10T02-40-11-359Z, run-2026-10-10T03-09-14-920Z
PC05pooler-checkoutpc05-checkout-matrix.tsrun-2026-10-23T08-11-43-542Z, run-2026-10-23T08-11-48-633Z
PC06pooler-checkoutpc06-login-delay.tsrun-2026-10-23T08-11-43-542Z, run-2026-10-23T08-11-48-633Z
PC08pooler-checkoutpc08-pgjdbc-hikari.tsrun-2026-11-04T09-11-22-458Z, run-2026-11-04T09-24-08-535Z, run-2026-11-04T11-18-37-198Z
T11privatelink-awsRUNLOG-
  1. 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 ↩

  2. Supabase, “Connecting to Postgres - Transaction mode limitations,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres#transaction-mode-limitations ↩ ↩2

  3. pgJDBC, “Initializing the Driver,” jdbc.postgresql.org. https://jdbc.postgresql.org/documentation/use/ ↩ ↩2 ↩3

  4. Supabase, “Compute and Disk,” Supabase Docs. https://supabase.com/docs/guides/platform/compute-and-disk ↩

  5. Supabase, “Connecting to Postgres - Dedicated pooler,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres#dedicated-pooler ↩ ↩2