How to fix LangGraph's Postgres checkpointer SSL errors under load
If you’ve wired up AsyncPostgresSaver or PostgresSaver from langgraph-checkpoint-postgres and everything worked fine in dev, then started throwing psycopg.OperationalError: SSL error: bad length or SSL SYSCALL error: EOF detected the moment you put real concurrent traffic through it, you’re not imagining a code bug. This is a connection-pool problem dressed up as a TLS problem, and it’s common enough that it has its own open issue on the LangGraph repo, where operators describe checkpoint writes failing intermittently under load, the error pointing at the TLS layer instead of anything you’d normally touch.
This is for anyone running a LangGraph agent in production with Postgres-backed persistence, usually against a managed database like RDS, Supabase, or Cloud SQL, sometimes behind a pooler like PgBouncer. It applies once you’ve moved past a single dev connection into concurrent graph runs, background workers, or a load test.
By the end of this you’ll understand why the error shows up as an SSL failure when the real cause is a dead TCP connection the pool didn’t know was dead, and you’ll have pool sizing and keepalive settings that stop it from recurring. None of it requires switching providers or disabling SSL, the wrong fix, explained below.
what you need
- a LangGraph app already using
PostgresSaverorAsyncPostgresSaverfromlanggraph-checkpoint-postgres - Python 3.11 or newer, with
psycopg[binary,pool]3.x installed (this is what the checkpointer’s connection pool is built on) - a Postgres instance reachable over SSL, whether that’s Amazon RDS, Supabase, Google Cloud SQL, or self-managed with a real certificate
- shell or console access to run
psqland querypg_stat_activityon that instance - PgBouncer or another pooler, if you already have one sitting in front of Postgres (not required to follow this, but the steps differ slightly if you do)
- a way to generate concurrent load for testing, a plain
asyncioscript withasyncio.gatheris enough, no paid load-testing tool needed - 30 to 60 minutes and no additional cost beyond the database instance you’re already running
step by step
1. confirm it’s pool exhaustion, not a one-off network blip
Before changing anything, correlate the error timing with your connection count. Connect to Postgres and run:
select count(*), state from pg_stat_activity
where application_name like '%psycopg%' or usename = 'your_app_user'
group by state;
Run this while your app is under load, and again right when an SSL error fires. If the count is bouncing near max_connections (check it with show max_connections;), or idle connections sit far longer than your queries take, you’re looking at a pool problem, not a flaky network.
if it breaks: if the count never gets close to the limit and the error is genuinely rare and one-off, this may be a transient network drop, not the pattern this guide fixes. Check your cloud provider’s network status page before going further.
2. look at how your checkpointer is actually constructing its connection
Most people wire up the checkpointer with a single connection string and let the library pick defaults:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
DB_URI = "postgresql://user:pass@db-host:5432/mydb?sslmode=require"
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
await checkpointer.setup()
from_conn_string builds a psycopg_pool.AsyncConnectionPool with whatever defaults psycopg_pool ships. Under low traffic that’s invisible. Under concurrent load, it’s the whole problem: no explicit sizing, no keepalives, and no dead-connection check.
if it breaks: if you’re constructing the pool manually already and still seeing this, skip to step 4, your issue is more likely keepalives than sizing.
3. build the pool explicitly with real sizing
Replace the shortcut with an explicit AsyncConnectionPool, sized for your actual concurrency, not the library default:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
DB_URI = "postgresql://user:pass@db-host:5432/mydb?sslmode=require"
pool = AsyncConnectionPool(
conninfo=DB_URI,
min_size=5,
max_size=20,
max_idle=300,
timeout=30,
open=False,
)
await pool.open()
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup()
max_size should be comfortably under your Postgres max_connections, accounting for every other service sharing that database. max_idle=300 tells the pool to close connections that have sat unused for five minutes, rather than letting them go stale.
if it breaks: set max_size too low and you’ll trade the SSL error for connection pool timeouts (psycopg_pool.PoolTimeout). Raise it in small steps while watching pg_stat_activity, don’t guess a big number.
4. add TCP keepalives so dead connections get caught before reuse
This is the step that actually fixes the “bad length” error. The failure happens when a connection sits idle in the pool, a NAT gateway, load balancer, or cloud firewall silently drops the underlying TCP session (most of them have idle timeouts well under an hour), and then the pool hands that connection back to your app for a checkpoint write. psycopg sends encrypted bytes down a socket the OS thinks is still open, the far end has already closed it, and what comes back is a truncated TLS record, which decrypts as garbage, which psycopg reports as SSL error: bad length. It’s not a certificate problem or a TLS version mismatch, it’s a stale socket problem wearing a TLS error message.
Fix it by having the OS actively probe idle connections so dead ones get evicted before your app tries to use them:
pool = AsyncConnectionPool(
conninfo=DB_URI,
min_size=5,
max_size=20,
max_idle=300,
timeout=30,
kwargs={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 10,
"keepalives_count": 3,
},
open=False,
)
That sends a TCP keepalive probe after 30 seconds of idle, every 10 seconds after that, and gives up after 3 missed probes, well inside most cloud idle-connection windows. These parameters are documented in PostgreSQL’s libpq connection parameters reference, and psycopg passes them straight through to libpq.
if it breaks: some managed networking layers (certain VPC peering setups, some corporate proxies) ignore or strip keepalive probes. If the error persists after this change, check whether your DB traffic actually goes through a NAT gateway or firewall with its own idle timeout, and lower keepalives_idle below that vendor’s stated timeout.
5. if you’re behind PgBouncer, match its idle timeout too
A pooler in front of Postgres adds a second place connections can go stale. In pgbouncer.ini:
[pgbouncer]
pool_mode = session
server_idle_timeout = 20
server_tls_sslmode = require
server_idle_timeout should be shorter than whatever idle timeout sits between PgBouncer and your app, not just between PgBouncer and Postgres. Full option reference is in PgBouncer’s config documentation.
if it breaks: if you’re on transaction pooling mode and start seeing odd parameter-binding errors after this change (not SSL errors, different errors), that’s psycopg3’s server-side binding colliding with transaction-mode pooling, a known limitation. Switch to session mode for the checkpointer’s connections, or a dedicated database/port for it.
6. turn on a connection health check at checkout
Keepalives reduce how often a dead connection reaches your app. A check on checkout catches the rest:
from psycopg_pool import AsyncConnectionPool
pool = AsyncConnectionPool(
conninfo=DB_URI,
min_size=5,
max_size=20,
max_idle=300,
timeout=30,
check=AsyncConnectionPool.check_connection,
kwargs={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 10,
"keepalives_count": 3,
},
open=False,
)
check_connection runs a cheap validation before handing a connection to your code, so a connection that died between probes gets replaced instead of used.
if it breaks: this adds a small amount of latency per checkout under very high throughput. If that matters at your scale, rely on keepalives and max_idle alone and drop this step, most operators don’t need both.
7. load test it before trusting it
Don’t ship this on faith. A minimal concurrency test:
import asyncio
async def run_one(graph, config):
return await graph.ainvoke({"messages": [("user", "test")]}, config)
async def main():
configs = [{"configurable": {"thread_id": str(i)}} for i in range(50)]
results = await asyncio.gather(*(run_one(graph, c) for c in configs))
print(f"{len(results)} runs completed")
asyncio.run(main())
Run this against your dev database with the old pool config first to reproduce the error, then again with the fixes from steps 3 to 6. You should see it disappear.
if it breaks: if the error still shows up at 50 concurrent runs, your max_size is probably still under actual demand, or you have another process on the same database eating into max_connections. Go back to step 1’s query while the test runs.
8. keep watching connection state in production, not just at launch
This class of bug tends to come back weeks later when traffic patterns shift. If you’re not already capturing what your checkpoints are doing and why, our piece on observing AI agent traces, replays, and cost covers what to instrument so you catch pool pressure before it becomes an outage instead of after.
common pitfalls
- setting
sslmode=disableto make the error go away. It works, technically, but most managed Postgres providers either block unencrypted connections outright or strongly discourage it, and you’ve removed encryption in transit to hide a pool bug that’s still there. - setting
max_sizevery high “to be safe.” Postgres has a hardmax_connectionsceiling (commonly 100 to 500 depending on instance size on RDS and Cloud SQL), shared across every service hitting that database. A generous pool on one app can starve everything else. - forgetting to close pools on process restart, especially in dev with hot reload or in serverless functions that spin up fresh processes per invocation. Each abandoned pool leaves connections open until the database’s own idle timeout kills them, which is exactly the stale-connection scenario this guide fixes.
- assuming the checkpointer’s persistence layer is separate from the rest of your agent’s reliability story. If you’re also relying on it to resume interrupted runs or gate steps on a human, worth reading how to add human approval checkpoints to an AI agent alongside this, since a checkpoint write failure under load is exactly the moment an approval gate can silently lose state.
- never testing under real concurrency. Single-request dev testing will never surface this, it only shows up once you have enough simultaneous connections for one to go idle long enough to get killed by a middlebox.
scaling this
At roughly 10x, meaning somewhere between 5 and 20 concurrent LangGraph runs, the fixes above are usually the whole solution: an explicit pool with min_size=5, max_size=20, keepalives tuned to your cloud provider’s idle timeout, and a health check on checkout.
At roughly 100x, Postgres’s own max_connections becomes the real ceiling, especially on smaller managed instances. This is where you want a pooler in front of the database, either PgBouncer or your provider’s built-in equivalent (Supabase’s Supavisor is the same idea). You also need to account for every app process running its own pool: five worker containers each with max_size=20 adds up to 100 connections before you’ve counted anything else on that database.
At roughly 1000x, the pooler itself usually becomes the bottleneck, not Postgres. At this scale it’s worth running the pooler in cluster mode or behind a managed pooling tier, and separating checkpoint traffic from your application’s regular query traffic entirely, either a dedicated connection pool or a read replica for anything that doesn’t need to hit the primary. It’s also the point to revisit whether every single graph step needs a checkpoint write, or whether you can batch checkpoints for high-frequency loops, since reducing write volume is cheaper than scaling the pool further.
where to go next
If you’re building out the reliability side of a production LangGraph deployment, two things pair naturally with this fix. How to add human approval checkpoints to an AI agent covers the pattern that depends most directly on your checkpointer actually surviving load. Observing AI agents: traces, replays, and cost covers the monitoring you want in place so the next pool problem shows up as a dashboard alert instead of a production incident. And if you’re running agents beyond just LangGraph orchestration, our browser agent production checklist has more of this same category of infrastructure hardening. More tutorials like this one are indexed at the blog.
Written by Xavier Fok
disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-17.