Building a connection pool from scratch with `tokio-postgres` reveals what's happening inside `deadpool` and `bb8`
When I first wrote PostgreSQL integration code in Rust, I just grabbed deadpool-postgres and moved on with "it works, so good enough." But one day in production I ran into a situation where connections had silently dropped, and the first request of the morning blew up with an error — that's when I started asking, "What exactly is happening inside this pool?" I'll come back to how I resolved that incident at the end of this post.
So I built a mini connection pool from scratch using only tokio-postgres. The short answer is: the core of a connection pool comes down to three things — a VecDeque-based store, Semaphore-based concurrency control, and automatic return via the Drop trait. Once you implement these three by hand, it becomes clear what design choices deadpool and bb8 each made, and why they differ in behavior.
This post assumes you're already comfortable with Rust async/await and are running or considering migrating to a backend service that uses Rust with PostgreSQL. Familiarity with the tokio-postgres API is assumed.
Why You Shouldn't Create a New Connection per Request
The Client in tokio-postgres is internally a handle connected via a channel to a background I/O task. It can be Cloned, but a cloned client shares the same single TCP connection. That means Clone alone doesn't increase parallelism, and work that requires isolated session state — like transactions — needs separate physical connections. That's why the pool concept exists: keeping multiple independent TCP connections ready.
Another reason is connection cost. TCP handshake + TLS negotiation (if used) + PostgreSQL authentication protocol repeating on every request adds up to non-negligible latency.
// Basic tokio-postgres connection
let (client, connection) = tokio_postgres::connect(
"host=localhost user=postgres dbname=mydb",
NoTls,
).await?;
// Spin up the connection as a background I/O task
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {}", e);
}
});The pair returned by connect() — Client (the request handle) and Connection (the actual I/O loop) — goes through this negotiation every time if you create it fresh per request. Avoiding that repetition and maintaining multiple independent connections simultaneously are the two reasons you need a pool.
Building a Mini Connection Pool by Hand
Step 1: Skeleton Structure
Start with the simplest form. Arc<Mutex<VecDeque<Client>>> is the store that holds idle connections.
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
use tokio_postgres::{Client, Config, NoTls};
pub struct Pool {
inner: Arc<Mutex<VecDeque<Client>>>,
sem: Arc<Semaphore>,
config: Config,
}
impl Pool {
pub fn new(config: Config, max_size: usize) -> Self {
Pool {
inner: Arc::new(Mutex::new(VecDeque::new())),
sem: Arc::new(Semaphore::new(max_size)),
config,
}
}
}The Semaphore is the key here. With only VecDeque, when the pool is empty you could keep creating new connections and exceed max_size. The Semaphore enforces a hard cap on slot count.
Step 2: Automatic Return with the Guard Pattern
The PooledConn returned by get() automatically returns the connection to the pool when it's Dropped.
pub struct PooledConn {
client: Option<Client>,
pool: Arc<Mutex<VecDeque<Client>>>,
_permit: OwnedSemaphorePermit, // Automatically releases the Semaphore slot on Drop
}
impl std::ops::Deref for PooledConn {
type Target = Client;
fn deref(&self) -> &Self::Target {
self.client.as_ref().unwrap()
}
}
impl std::ops::DerefMut for PooledConn {
fn deref_mut(&mut self) -> &mut Self::Target {
self.client.as_mut().unwrap()
}
}
impl Drop for PooledConn {
fn drop(&mut self) {
if let Some(client) = self.client.take() {
if !client.is_closed() {
// Drop runs in a sync context, so we work around it with tokio::spawn
let pool = self.pool.clone();
tokio::spawn(async move {
pool.lock().await.push_back(client);
});
}
// Broken connections are simply discarded — _permit is returned automatically
}
}
}The fact that Drop runs in a sync context is confusing at first. Using tokio::sync::Mutex requires .await, so you can't acquire the lock directly inside Drop. You can work around it with tokio::spawn as shown above, or avoid the problem entirely by using std::sync::Mutex. Storing OwnedSemaphorePermit in the _permit field causes the slot to be returned automatically when PooledConn is dropped.
Step 3: Implementing get()
impl Pool {
pub async fn get(&self) -> Result<PooledConn, Box<dyn std::error::Error + Send + Sync>> {
// Wait until a slot is available (prevents exceeding max_size)
let permit = Arc::clone(&self.sem).acquire_owned().await?;
let client = {
let mut inner = self.inner.lock().await;
inner.pop_front()
};
// _ handles two cases together:
// 1) None: no idle connections available
// 2) Some(closed): an idle connection exists but is already closed
// In both cases, create a new connection and return it.
let client = match client {
Some(c) if !c.is_closed() => c,
_ => self.create_client().await?,
};
Ok(PooledConn {
client: Some(client),
pool: Arc::clone(&self.inner),
_permit: permit,
})
}
async fn create_client(&self) -> Result<Client, Box<dyn std::error::Error + Send + Sync>> {
let (client, connection) = self.config.connect(NoTls).await?;
// In production, logging the error as shown in the intro example is recommended.
// Ignored here to keep the flow minimal.
tokio::spawn(async move { let _ = connection.await; });
Ok(client)
}
}The flow is clearer as a diagram.
This is the essential skeleton of what deadpool and bb8 do. Both libraries build on this foundation by adding a Manager abstraction, recycling strategies, monitoring, and more.
Inside deadpool and bb8: Where Do They Differ?
deadpool's Design Choices
The deadpool managed module wraps Pool<Manager> in an Arc for sharing, and internally holds an idle object store and wait-control structure. The interface users need to implement is the Manager trait.
// deadpool Manager trait (conceptual example — actual signature varies by version)
pub trait Manager {
type Type;
type Error;
async fn create(&self) -> Result<Self::Type, Self::Error>;
async fn recycle(
&self,
obj: &mut Self::Type,
metrics: &Metrics,
) -> RecycleResult<Self::Error>;
}The recycle() strategy is deadpool's biggest differentiator. The RecyclingMethod section of the deadpool-postgres official docs defines the following strategies.
| Strategy | Behavior | Overhead |
|---|---|---|
Fast |
Checks client.is_closed() only |
Minimal |
Verified |
Executes a validation query | 1 RTT |
Clean |
Runs a session-state reset query as well | 1 RTT + α |
Custom |
User-defined query | Depends on query |
Details like the default value or when a hook was added change over time, so before putting code into production, check the CHANGELOG and the RecyclingMethod docs for the version you're using. What matters conceptually is that these three strategies leave it to the user to decide where to strike the balance between "connection reuse benefit" and "integrity check cost." Running a validation query on every checkout dilutes much of the pool's benefit, but skipping verification means you won't immediately detect a silently dropped connection caused by external factors like network interruptions — that's the tradeoff.
bb8's Design Choices
bb8 splits the connection lifecycle into three methods via the ManageConnection trait.
// bb8 ManageConnection trait (conceptual example)
pub trait ManageConnection: Send + Sync + 'static {
type Connection: Send + 'static;
type Error: std::error::Error + Send + Sync + 'static;
async fn connect(&self) -> Result<Self::Connection, Self::Error>;
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error>;
fn has_broken(&self, conn: &mut Self::Connection) -> bool;
}These three methods correspond to deadpool's recycling strategies. connect() handles new connections, is_valid() handles integrity checks, and has_broken() decides whether to discard. Notably, is_valid() in the bb8-postgres implementation actually executes a validation query — this is the key difference from deadpool's Fast strategy. The integrity guarantee is stronger, but under high request frequency, this cost accumulates.
For performance differences, there was a related discussion in the community at axum Discussion #2493 — check the original thread for actual numbers. Rather than declaring one faster than the other, it's more accurate to understand that the optimal choice depends on your workload's request frequency and connection reliability requirements.
Implementation details of how bb8 handles queued requests may change between versions, so if you're curious, open the PoolInner section of the bb8 source code directly. Here I'll just note the contrast with deadpool, which serializes slot waiting via a Semaphore.
What It Means That bb8-postgres Has No Prepared Statement Cache
deadpool-postgres provides prepare_cached() through a built-in statement cache in ClientWrapper. bb8-postgres exposes tokio_postgres::Client directly, without this layer. This isn't simply a missing feature — it's a natural consequence of bb8's design focus on being a general-purpose connection pool framework. bb8 has adapters for various backends like Redis and MongoDB, while deadpool-postgres has built a thick layer of Postgres-specific conveniences. If you need caching, the practical answer is to either layer it on top of bb8-postgres yourself or choose deadpool-postgres.
Production Code with axum
The combination of axum + tokio + deadpool-postgres has become one of the common choices for Rust async backends. Let's look at axum's State<AppState> pattern.
use axum::{extract::State, routing::get, Router};
use deadpool_postgres::{Config, Pool, Runtime};
use tokio_postgres::NoTls;
#[derive(Clone)]
struct AppState {
pool: Pool,
}
async fn get_user(State(state): State<AppState>) -> String {
// Automatically returned to the pool when the function ends
let client = state.pool.get().await.unwrap();
// prepare_cached skips parsing and planning overhead on repeated calls
let stmt = client
.prepare_cached("SELECT name FROM users WHERE id = $1")
.await
.unwrap();
let rows = client.query(&stmt, &[&1i32]).await.unwrap();
rows.first()
.map(|r| r.get::<_, String>(0))
.unwrap_or_default()
}
#[tokio::main]
async fn main() {
let mut cfg = Config::new();
cfg.host = Some("localhost".into());
cfg.dbname = Some("mydb".into());
cfg.user = Some("postgres".into());
let pool = cfg.create_pool(Some(Runtime::Tokio1), NoTls).unwrap();
let app = Router::new()
.route("/user", get(get_user))
.with_state(AppState { pool });
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}prepare_cached() is a feature provided by deadpool-postgres's ClientWrapper that pulls from the statement cache on repeated executions of the same SQL, skipping parsing and planning overhead. If you ever need to invalidate the cache, check the StatementCache section of the deadpool-postgres docs to see what interface your version provides.
Tradeoffs and Common Mistakes
Choosing the Right Tool for the Situation
| Item | Custom impl | deadpool-postgres | bb8-postgres | sqlx built-in pool |
|---|---|---|---|---|
| Learning cost | High | Low | Medium | Low |
| Recycling approach | Custom | Selectable strategy | is_valid-based validation | Own approach |
| Prepared statement cache | Custom | Built-in | None (by design) | Built-in |
| Compile-time query validation | No | No | No | Yes |
| Direct tokio-postgres access | Yes | Yes | Yes | No |
| Customizability | Fully free | Manager hook extension | Manager hook extension | Relatively limited |
| Other backend support | Custom | Separate adapters needed | Redis, MongoDB adapters available | No |
Common Pitfalls When Implementing from Scratch
Omitting the Semaphore: Using only VecDeque means new connections keep getting created when the pool is empty, exceeding max_size. This typically only surfaces under load testing, making it easy to miss.
tokio::sync::Mutex vs std::sync::Mutex: If you need to hold a lock across an .await point, you must use tokio::sync::Mutex. But since you can't use .await inside Drop, when implementing return logic in Drop you need to use std::sync::Mutex or work around it with tokio::spawn. Honestly, this was the most confusing part for me.
Mutex poisoning: If a panic occurs inside a std::sync::Mutex, the Mutex becomes poisoned. It's worth guarding against this with .unwrap_or_else(|e| e.into_inner()) or explicit error handling instead of .unwrap().
Connection lifetime management: PostgreSQL server settings like idle_in_transaction_session_timeout close connections that have been idle too long. Without a max_lifetime concept, silently dropped connections accumulate in the pool. deadpool-postgres supports this as a configuration option.
No timeout handling: If get() waits forever, handlers stall. Wrapping the wait with tokio::time::timeout is essential in production.
So How Did That Incident End?
Let me come back to the production incident I mentioned at the start. The symptom was a pattern where idle connections accumulated during low-traffic hours overnight, and the first morning request would pull one of those connections and immediately blow up with an error. Tracing through the logs, I found that the firewall/load balancer was silently terminating idle TCP connections, and our pool's is_closed() check alone couldn't detect that state — because is_closed() returns false as long as the TCP socket still appears open from our process's perspective.
My fix had two parts.
- Switching the recycling strategy from
Fastto aVerified-style approach: By paying the RTT cost of one validation query, I made it so that a dead connection found at checkout is replaced with a fresh one. Our service's request frequency wasn't extreme enough to make this cost prohibitive. - Setting
max_lifetimeshorter than the firewall's idle timeout: This put an upper bound on how long connections could sit in the pool, preventing aged connections from accumulating in the first place.
Two lessons came out of this. First, a library's defaults are optimized for the average workload the library author had in mind — not your workload. Second, a connection pool is a system where server-side timeouts, intermediate network devices, and reuse policies all intertwine behind a single get() call — tuning any one piece in isolation won't solve it.
Since that incident, whenever I attach a connection pool to a new service, I always check three things first: the actual behavior of the recycling policy, the relationship between max_lifetime and the server-side idle timeout, and the timeout on get(). If you're already using a pool, take a look at pool.status() (deadpool family) to see idle/waiting state. If it doesn't match your expectations, that's your starting point for tuning.
References
- deadpool-postgres official docs (docs.rs)
- deadpool core managed module docs
- deadpool CHANGELOG (GitHub)
- bb8 GitHub repository
- bb8-postgres library info (lib.rs)
- tokio-postgres Client API docs
- axum Discussion #2493 (GitHub)
- Implement PostgreSQL Pool connection in Rust (craft.ai)
- Rust async pool benchmark (Astro36/rust-pool-benchmark)
- Mastering Tokio Semaphores in Rust (DEV Community)
- Rust Forum: async connection pool implementation discussion