Decorative background gradient
Back to Blog
BackendRedisDatabase

Session Stores and Database Connection Pooling Explained

Why in-memory sessions break horizontally scaled apps, how to move session state to Redis correctly, and how connection pooling keeps your database from falling over under concurrent load.

Session Stores and Database Connection Pooling Explained

Two backend problems tend to surface at the exact same moment — usually the first time an app runs on more than one server: sessions that mysteriously log users out, and a database that starts refusing new connections under load. Both come from the same root cause: treating a per-instance resource (memory, a connection) as if it were shared, when it isn't.

The Problem with In-Memory Sessions

A default session setup often looks like this — simple, and completely broken once you scale past one instance.

ts

This works fine in local development and breaks in production the moment you run two instances behind a load balancer. A user logs in on instance A, their session is stored in instance A's memory, and their next request — routed to instance B by the load balancer — finds no session at all. The symptom looks like random, intermittent logouts, which makes it confusing to debug if you don't already know to look for in-memory session storage as the cause.

Fixing It with a Shared Session Store

Moving sessions to Redis makes them readable from any application instance, since Redis is a separate service every instance connects to over the network.

ts

Now any instance handling a request can look up the session by the ID in the user's cookie, regardless of which instance created it. This is the same principle behind all stateless service design: state that needs to survive across requests and instances has to live somewhere every instance can reach, not in a single process's memory.

Session TTL: A Different Kind of Expiration

The cookie.maxAge above controls how long the session stays valid — a policy decision, not a performance one.

ts

Set this based on your actual security and UX requirements: a banking app might expire sessions after 15 minutes of inactivity, while a content site might keep users logged in for weeks. This is unrelated to how a database connection pool times out connections — the two are easy to conflate because both involve a TTL, but they solve different problems (session lifetime vs. connection resource management) and should be tuned independently.

What a Connection Pool Actually Solves

Opening a database connection involves a TCP handshake, TLS negotiation, and authentication — work that's slow compared to running a typical query. Doing this per-request is wasteful.

ts

A connection pool opens a fixed number of connections once, at startup, and hands them out to requests as needed, returning each connection to the pool when the query finishes instead of closing it.

ts

pool.query automatically checks out a connection, runs the query, and returns the connection to the pool — the connection itself stays open in the background across many requests.

Sizing a Connection Pool Correctly

The instinct to set max as high as possible is wrong — every application instance's pool competes for the same finite max_connections limit on the database.

text

This looks safe on paper, but leaves little room for migrations, admin connections, or a traffic spike that spins up more instances. A reasonable starting formula is (CPU cores × 2) + 1 connections per instance — enough to keep cores busy without wildly overshooting what a single instance can usefully use concurrently — then multiplying by your maximum expected instance count to check the total against the database's actual limit.

ts

connectionTimeoutMillis matters too — without it, a request that can't get a connection from an exhausted pool hangs indefinitely instead of failing fast with an error you can actually handle.

When to Add an External Pooler (PgBouncer)

Once you're running many instances — serverless functions that can scale to dozens of concurrent invocations, or an autoscaled container fleet — per-instance pooling alone doesn't scale, because instance count itself becomes the bottleneck against max_connections.

text

PgBouncer sits between your application and the database, accepting many client connections and multiplexing them onto a much smaller pool of real database connections, handing a real connection to a client only for the duration of a transaction. Point your application's DATABASE_URL at PgBouncer instead of the database directly, and instance count stops being coupled to database connection count.

text

This is the standard fix for the "too many connections" errors that show up specifically in serverless Postgres deployments, where each function invocation might otherwise try to hold its own connection.

Key Takeaways

In-memory sessions break the instant you scale past one instance — moving them to a shared store like Redis is what makes horizontal scaling of stateful-feeling features (login, carts) actually work. Connection pooling avoids the cost of opening a new database connection per request, but pool size has to be chosen against your database's real connection limit, multiplied across every instance that holds one — not maximized per instance in isolation. When instance count itself becomes the bottleneck, an external pooler like PgBouncer decouples application scaling from database connection limits in a way per-instance pooling alone cannot.

Frequently Asked Questions

Why can't I just store sessions in server memory?

In-memory sessions only exist on the specific server instance that created them. The moment you run more than one instance behind a load balancer, a user's next request can land on a different instance that has never seen their session, logging them out or losing their state unpredictably depending on which server handles each request.

What is a database connection pool and why do I need one?

A connection pool is a set of database connections that are opened once and reused across many queries, instead of opening and closing a new connection for every request. Opening a connection involves a TCP handshake and authentication that's slow relative to running a query, so reusing connections is significantly faster and reduces load on the database.

How do I choose the right connection pool size?

A common starting formula is (number of CPU cores × 2) + 1 connections per application instance, then multiply by your instance count to check it against your database's max_connections setting. If that total approaches or exceeds max_connections, put an external pooler like PgBouncer in front of the database instead of raising the limit indefinitely.

What's the difference between PgBouncer and my ORM's built-in pool?

Your ORM's pool manages connections within a single application instance's process. PgBouncer sits between all of your application instances and the database as a separate service, multiplexing many client connections onto a much smaller number of actual database connections — this matters most when you run many instances (serverless functions, autoscaled containers) that would otherwise each hold their own pool against the database directly.

Working on something similar? Take a look at my services and case studies, or book a free call to talk about your idea.

Related Articles

Trending Topics