Lambda and relational databases
One of the biggest surprises I encountered early in my career was watching a production Lambda function suddenly start failing with "too many connections" errors, even though the application logic was rock solid. I'd scaled the function horizontally to handle thousands of concurrent requests, but I hadn't thought about what happens when each invocation opens its own database connection. This scenario taught me that Lambda and relational databases have a fundamentally different scale model, and without proper connection management, your database becomes the bottleneck, not your compute layer.
In this article, I'll walk you through the patterns and tools that actually work when you're running thousands of concurrent Lambda functions against a relational database. This isn't theoretical—these are the strategies I've used to stabilize e-commerce platforms, trading systems, and SaaS applications that all faced the same connection explosion problem.
The core problem: connection exhaustion
When you write a Lambda function that opens a database connection, you're often thinking single-threaded. The function runs, queries the database, returns a result, done. But AWS Lambda doesn't work that way in production. You might have 100, 500, or 5,000 concurrent executions happening at the same moment. If each one opens a fresh TCP connection to your RDS instance, you'll hit the connection limit on most database engines within seconds.
PostgreSQL defaults to 100 max connections. MySQL defaults to 150. A moderately popular API backed by Lambda can easily exceed these limits in a single second. When you do, new invocations fail immediately with connection refused errors, and your error rates spike catastrophically.
I've seen this happen twice in my career—once in a ride-hailing platform during surge pricing calculations, and again in a travel booking system during flash sales. Both times, the application code was fine; the infrastructure just wasn't designed for this traffic pattern.
Use RDS Proxy to pool connections
RDS Proxy is the first line of defense, and honestly, it should be considered mandatory for any Lambda-to-RDS setup handling moderate to high concurrency. RDS Proxy sits between your Lambda functions and your RDS instance, maintaining a pool of persistent database connections and multiplexing your Lambda requests across them.
Instead of each Lambda invocation creating a new connection, every invocation checks out a connection from the pool, uses it, and returns it. This dramatically reduces the load on your database. I typically see 10-50x reduction in actual database connections with RDS Proxy enabled, even with thousands of concurrent Lambda invocations.
Setting up RDS Proxy is straightforward. Here's the architecture:
AWS Lambda (1000s concurrent)
→ RDS Proxy (connection pool, e.g., 100 connections)
→ RDS Instance (max connections limit respected)You create a proxy in the RDS console, point it at your database, configure the connection pool size, and then update your Lambda connection string to point at the proxy endpoint instead of the RDS endpoint directly.
A typical configuration I use:
Max connections: 100
Max idle connections: 50
Connection timeout: 120 seconds
Session timeout: 900 secondsThe key metric to monitor is DatabaseConnectionsBorrowed in CloudWatch. If this number consistently equals your max pool size, you need to increase it. If it's consistently low, you're over-provisioned.
I implemented RDS Proxy for a CRM system handling customer API requests last year. Peak connections dropped from 800 to 45, and database CPU utilization fell by 30%. The best part: no application code changes required.
Set reserved concurrency limits
Even with RDS Proxy, you still need to control how many Lambda functions can run simultaneously. Reserved concurrency acts as a circuit breaker for your database layer. It says: "No matter what, don't let more than X Lambda functions run at the same time."
Without reserved concurrency, a traffic spike or runaway loop can exhaust your entire Lambda service quota and crash your database anyway. With it, you fail gracefully—excess invocations queue up and retry, but you don't accidentally run 10,000 concurrent functions against a connection pool of 50.
The formula I use is roughly:
Reserved concurrency = (RDS Proxy max connections) / (connections per invocation)
Most modern applications use connection pooling at the application level too, so a single Lambda invocation might only need 1-3 database connections. If your RDS Proxy has 100 connections and each invocation uses 2, set reserved concurrency to around 40-50 to leave headroom for connection churn.
You set this in the Lambda console under Concurrency, or via the AWS CLI:
aws lambda put-function-concurrency \
--function-name my-api-function \
--reserved-concurrent-executions 50I treat reserved concurrency as insurance. It costs nothing, takes two minutes to set up, and saves you from cascading failures when traffic spikes unexpectedly.
Reuse connections across invocations
This is where your application code matters. Lambda allows you to initialize code outside the handler function, and that code persists for the lifetime of the execution environment. This is your chance to build a connection pool or persistent database client that survives between invocations.
Here's a Node.js example using a connection pool:
const pool = new Pool({
host: process.env.DB_PROXY_ENDPOINT,
port: 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 2,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
exports.handler = async (event) => {
const client = await pool.connect();
try {
const result = await client.query('SELECT * FROM users WHERE id = $1', [event.userId]);
return { statusCode: 200, body: result.rows };
} finally {
client.release();
}
};The pool is created once when the container starts. Subsequent invocations reuse it. This means invocation 1 pays the cost of establishing connections; invocations 2-100 on the same container pay virtually nothing.
A similar pattern in .NET:
private static readonly NpgsqlDataSource dataSource = new NpgsqlDataSourceBuilder(
Environment.GetEnvironmentVariable("DB_PROXY_ENDPOINT")
).Build();
public async Task Handler(APIGatewayProxyRequest request)
{
await using var connection = dataSource.OpenConnection();
var result = await connection.QueryAsync("SELECT * FROM users");
return new APIGatewayProxyResponse { StatusCode = 200 };
}The key difference from the old pattern: initialize your data source/connection pool outside the handler, not inside it. Lambda reuses the same container for multiple invocations, so this pool object survives and serves multiple requests.
I've measured this in production. With per-invocation connection creation, median database query latency was 45ms. With connection reuse and pooling, it dropped to 8ms. That's not just faster—that's fewer connection churn events, which means RDS Proxy stays healthier under load.
Consider the Data API for Aurora
If you're using Aurora (MySQL or PostgreSQL), there's an alternative to RDS Proxy worth considering: the Aurora Data API. It's an HTTP endpoint that handles connection pooling automatically, so your Lambda functions don't manage connections at all.
With the Data API, your Lambda makes HTTP POST requests to an AWS-managed endpoint, and Aurora handles the pooling behind the scenes. It's simpler conceptually—no persistent connections in your code—but it has trade-offs.
Advantages:
- No connection management code needed
- Automatic scaling of connection pools
- Works well with temporary credentials (IAM authentication)
- Slightly simpler for teams new to database architecture
Disadvantages:
- HTTP overhead compared to native database protocols
- Higher latency (typically 5-20ms additional per query)
- Not suitable for high-frequency, low-latency workloads
- Limited transaction support compared to native connections
I've used the Data API in two systems: a content management platform and a low-frequency batch job processor. Both worked fine. But for my e-commerce platform and trading system, I stuck with RDS Proxy plus connection pooling because the latency difference matters when you're doing dozens of queries per request.
If your workload is bursty, low-frequency, or mostly reads, the Data API is a solid choice. If you need predictable sub-10ms latency or complex transactions, go with RDS Proxy and application-level pooling.
Common mistakes
Mistake 1: Relying only on RDS Proxy without reserved concurrency. I see this regularly. RDS Proxy helps, but it's not enough. A traffic spike can still overwhelm your database if thousands of Lambda invocations try to queue simultaneously. Always set reserved concurrency.
Mistake 2: Creating a new connection pool per invocation. Some developers initialize their database client inside the handler function to "keep things clean." This defeats the whole purpose of Lambda's persistent container layer. Initialize it outside the handler, reuse it.
Mistake 3: Not monitoring connection metrics. Set up CloudWatch alarms for DatabaseConnectionsBorrowed and RDS CPU. If you're hitting the connection pool limit regularly, that's a signal to increase the pool size or reduce reserved concurrency.
Mistake 4: Skipping transactions. Because connections are pooled across many concurrent requests, you need to be extra careful about transaction isolation. Always use explicit transactions for multi-step operations, or you'll get race conditions.
Mistake 5: Choosing Data API when you need sub-10ms latency. Data API is convenient, but the HTTP overhead adds latency. If you're building a real-time trading system or high-frequency API, you'll notice. Use RDS Proxy instead.
Key takeaways
- Lambda's concurrent execution model can exhaust relational database connections in seconds. Plan for this from day one.
- RDS Proxy is essential for any Lambda-to-RDS system handling moderate to high concurrency. It pools connections and multiplexes invocations, reducing database connection count by 10-50x.
- Set reserved concurrency on your Lambda functions to prevent cascading failures. Use the formula: max pool size / connections per invocation.
- Initialize database clients and connection pools outside your handler function so they persist across invocations. This dramatically reduces connection churn and latency.
- Monitor DatabaseConnectionsBorrowed in CloudWatch. If it's consistently at the max, increase pool size or reduce concurrency.
- The Aurora Data API is a viable alternative if your workload is bursty and latency-tolerant. For high-frequency, low-latency systems, use RDS Proxy plus application-level pooling.
- Test your connection handling under realistic load before production. A sudden traffic spike reveals architectural weaknesses quickly.






Comments
Loading comments…