Haskell Connection Pooling for Postgres Services
Your Haskell service passes every load test on a laptop, then falls over in production with "too many clients already" from Postgres or with requests hanging for 30 seconds before timing out.

Your Haskell service passes every load test on a laptop, then falls over in production with "too many clients already" from Postgres or with requests hanging for 30 seconds before timing out. Nine times out of ten the culprit is not the database and not your queries. It is how the service hands out connections, and Haskell connection pooling has a few sharp edges that the library defaults do not warn you about.
This guide walks through how a pool actually behaves under load, how to size it with arithmetic instead of guesses, and the mistakes I keep seeing in code reviews of Warp and Servant backends talking to Postgres.
What Haskell connection pooling actually does
A pool keeps a set of open database connections and lends them to request handlers. Opening a fresh Postgres connection costs a TCP handshake, TLS negotiation if you use it, authentication and a backend process fork on the server. On a typical cloud setup that is 5 to 20 milliseconds. Borrowing an idle connection from a pool costs microseconds.
The most common choice is the resource-pool package, which sits underneath postgresql-simple setups, persistent, and many hand-rolled stacks. Hasql users usually reach for hasql-pool, which follows the same ideas with a slightly different API. Both give you three knobs that matter:
- Maximum size: the hard cap on open connections.
- Idle timeout: how long an unused connection survives before it is closed.
- Stripes (resource-pool) or acquisition timeout (hasql-pool): how the pool is partitioned or how long a caller waits.
Since resource-pool 0.4, the configuration is built with defaultPoolConfig and the stripe count defaults to the number of capabilities, which is where many teams get surprised.
Sizing the pool with numbers, not hope
Start from the database side. Postgres ships with max_connections set to 100. A managed instance on a small plan might allow 50 to 200. Reserve around 10 for superuser access, migrations and monitoring. Whatever remains is the budget for every replica of every service that talks to that database.
Here is a worked example. You run 4 replicas of an API and 2 replicas of a background worker against a database with 100 connections:
| Item | Connections |
|---|---|
| Postgres max_connections | 100 |
| Reserved for admin and migrations | 10 |
| Worker replicas, 2 at 5 each | 10 |
| Left for API replicas | 80 |
| Per API replica, with headroom for a rolling deploy | 13 |
The rolling deploy line is the one people forget. During a deploy Kubernetes may briefly run 5 or 6 API pods instead of 4, and each new pod fills its pool as traffic arrives. If you sized each pod at 20, a deploy pushes you to 120 and Postgres starts refusing connections in the middle of your release.
Then check the size against throughput. If a typical request holds a connection for 8 milliseconds, one connection serves about 125 requests per second. Thirteen connections handle roughly 1,600 requests per second per pod before anyone waits. Most services never come close, which tells you the pool is rarely the throughput bottleneck. It is usually the safety valve.
The stripe trap in resource-pool
Stripes split the pool into independent sub-pools to reduce contention on a single MVar. Each Haskell capability tends to hit its own stripe. The catch is that the maximum size is divided across stripes, so a pool of 10 with 8 stripes gives you stripes holding one or two connections each.
Under uneven load, one stripe can be exhausted while others sit idle. A request scheduled on a busy capability waits even though the pool as a whole has free connections. On an 8 core box running with +RTS -N, this shows up as mysterious latency spikes that vanish when you test with a single core.
What to set instead
For pools under about 30 connections, set stripes to 1 explicitly with setNumStripes (Just 1). The contention you avoid with more stripes is measured in microseconds, while the waiting you cause is measured in milliseconds. Only reach for multiple stripes after profiling shows the pool MVar is actually hot, which in my experience means thousands of acquisitions per second per process.
Why a bigger pool is usually the wrong fix
The standard advice when requests queue for connections is to raise the pool size. I push back on that almost every time. Postgres runs one OS process per connection, and past a certain point more concurrent queries make every query slower because they fight over CPU, locks and shared buffers. A widely quoted starting formula from the Postgres community is roughly cores times 2 plus effective disk spindles, which for a 4 vCPU database lands near 10 active connections, not 100.
If handlers are waiting on the pool, ask why connections are held so long. The usual reasons:
- Doing non-database work inside withResource. Calling an HTTP API or rendering a large JSON response while the connection is checked out holds it for the full duration. Fetch the rows, release, then do the slow work.
- Long transactions. A transaction that reads, calls out to another service and then writes holds a connection and row locks the whole time.
- N plus 1 queries. Fifty small queries per request multiply hold time. Batch them.
- Lazy results escaping the scope. If a lazily decoded result is forced after release, you may end up doing work on a connection you no longer own, or holding thunks that reference it.
Fixing hold time often lets you shrink the pool and still lower latency. Watch it with the same discipline you would use when reading GHC's +RTS -s report before tuning the GC: measure first, then change one setting.
Exceptions, async and leaked connections
resource-pool's withResource uses bracket semantics. If the action throws, the connection is destroyed rather than returned, on the theory that it may be in a broken state. That is correct, but it means a handler that throws on every request churns connections constantly, paying the full setup cost each time. A sudden jump in Postgres connection rate with stable request rate is a strong hint that something is throwing inside the pool scope.
Async exceptions add another layer. When Warp kills a handler because the client disconnected, or when timeout fires, the exception lands wherever the thread happens to be. If that moment is mid-query, the connection is discarded, which is fine. Never use takeResource and putResource by hand unless you mask exceptions correctly, because a kill between the two leaks a pool entry permanently. After enough leaks, the pool is empty and every request hangs.
The same care applies at shutdown. Call destroyAllResources once traffic has drained so Postgres sees clean disconnects instead of dropped sockets. The ordering is covered in detail in our post on Haskell graceful shutdown for long running services.
Timeouts and what to measure
An unbounded wait for a connection is the most dangerous default. With hasql-pool, set an acquisition timeout such as 2 seconds so callers fail fast with a clear error. With resource-pool, wrap acquisition in timeout and return a 503 when it fires. A fast failure lets your load balancer retry another replica. A slow one ties up Warp threads and memory until the whole process degrades.
Export at least these metrics per pool:
- Connections in use and idle, sampled every few seconds.
- Time spent waiting to acquire, as a histogram.
- Connections created and destroyed per minute.
Wait time above a few milliseconds at p99 means hold time is too long or the pool is too small, in that order of likelihood. If you share pool state across threads in other ways, the patterns in Haskell STM for shared state in concurrent services help keep counters consistent without extra locks.
Frequently asked questions
How many connections should a Haskell connection pool have?
Divide the database's usable connections by the number of replicas you might run during a deploy, then check that number against expected throughput. For most small services this lands between 5 and 20. Start low and raise it only if acquisition wait time shows real queuing.
Should I use PgBouncer with a Haskell connection pool?
Yes, once you run many replicas or serverless workloads. Keep a small pool in each process and let PgBouncer in transaction mode multiplex onto fewer Postgres backends. Avoid prepared statements across transactions in that mode, or configure your library to disable them.
What idle timeout should I set for resource-pool?
Something between 30 and 300 seconds works for most services. Make it shorter than any idle timeout on firewalls or proxies between you and Postgres, which are often 350 seconds on cloud load balancers, so you never borrow a connection that was silently cut.
More in Systems
Systems
Haskell Graceful Shutdown for Long Running Services
Your Haskell service gets a SIGTERM during a deploy, the process dies mid-request, and somewhere a client sees a 502 while a...
Systems
How to Find and Fix a Haskell Space Leak in Production
Your service starts at 80 MB of resident memory, and three days later the container gets killed at 2 GB.
Systems
Haskell FFI for Calling C Libraries Without Crashes
The first time I wrapped a C library with the Haskell FFI, the service ran for eleven days before it segfaulted at three in the...