Using the RDS Data API Instead of Connection Pools
This guide is part of Serverless Function Connection Management. Every technique for running functions against a relational database is a way of managing a resource functions are bad at holding: a TCP connection. The Data API removes the connection entirely. Statements are sent over HTTPS to an AWS endpoint, which maintains its own connections to the Aurora DB cluster, so a function that scales from 10 to 500 instances opens no database connections at all:
# with pooled connections, at 500 concurrent instances
FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute
# with the Data API, the same traffic
200 OK ExecuteStatementResponse { records: [...] }
That is a real advantage and a real trade. The Data API adds HTTP overhead per statement, supports a subset of what a driver does, and bills per request. The objective is to decide, with numbers, whether it fits a given workload — and to configure it correctly where it does.
Rapid incident diagnosis
Establish whether connections are actually the problem. The Data API is the answer to connection exhaustion caused by instance count, not to slow queries:
aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name ConcurrentExecutions \
--dimensions Name=FunctionName,Value=orders-fn --statistics Maximum \
--start-time $(date -u -d '-1 hour' +%FT%TZ) --end-time $(date -u +%FT%TZ) --period 60 \
--query 'Datapoints[].Maximum' --output text | tr '\t' '\n' | sort -n | tail -1
Multiply by the per-instance pool size and compare with the DB cluster’s usable connections. If the product is comfortably inside the budget, the connection model is not the constraint and the Data API would add latency for nothing.
Check whether the workload suits an HTTP interface. Three characteristics decide it:
statements per invocation — each is an HTTPS round trip
result set size — results are serialised as JSON-like records
session requirements — no session state survives between calls
A function issuing two small statements per invocation fits well; one issuing thirty, or streaming a large result, does not.
If already using it, look at the error shapes. Data API failures are HTTP and service errors rather than driver errors:
| Error | Meaning |
|---|---|
BadRequestException: Communications link failure |
The DB cluster is unavailable or paused |
DatabaseResumingException |
Aurora Serverless is resuming; retry |
StatementTimeoutException |
The statement exceeded the API’s limit |
ThrottlingException |
Request rate above the account’s limit; back off |
SerializationException |
A result value could not be represented |
Measure the latency it adds. The API’s own latency shows up in CloudWatch and in the client’s timing:
data_api_latency ≈ http_round_trip + service_overhead + database_execution
Compare against a pooled connection from the same environment; the difference is what the architecture costs per statement.
Mathematical sizing or parameter formula
Latency. The relevant comparison is per statement, including the connection the pooled model would have reused:
pooled_statement ≈ query_time + tcp_round_trip
data_api_statement ≈ query_time + https_round_trip + service_overhead
In-region, an HTTPS call with TLS session reuse adds a few milliseconds over a plain TCP round trip. For a 3 ms query that is meaningful in relative terms and usually irrelevant in absolute ones; for a function making 20 statements, it is 20 times the difference.
added_latency_per_invocation = statements_per_invocation × per_statement_overhead
Two statements at 4 ms of overhead is 8 ms; twenty statements is 80 ms, at which point batching or a different model is needed.
Cost. The Data API bills per request, where a pooled connection bills nothing beyond the instance:
monthly_requests = invocations_per_month × statements_per_invocation
At 50 million invocations with 2 statements each, that is 100 million requests — a line item worth calculating before adopting it fleet-wide.
Concurrency. The API has its own request-rate limits per account and region, which replace the connection limit as the ceiling:
peak_requests_per_second = invocations_per_second × statements_per_invocation
At 2,000 invocations per second with 2 statements, 4,000 requests per second — check it against the current service quota rather than assuming.
Result size. Records are returned in a structured form with a response size limit; a query returning large rows or many of them must paginate:
paginate when: expected_rows × row_bytes approaches the response limit
Exact remediation & configuration
Enable the Data API and grant access:
aws rds modify-db-cluster --db-cluster-identifier orders-aurora --enable-http-endpoint --apply-immediately
{
"Effect": "Allow",
"Action": ["rds-data:ExecuteStatement", "rds-data:BatchExecuteStatement",
"rds-data:BeginTransaction", "rds-data:CommitTransaction", "rds-data:RollbackTransaction"],
"Resource": "arn:aws:rds:eu-west-1:123456789012:cluster:orders-aurora"
}
The credentials come from Secrets Manager, so the function also needs secretsmanager:GetSecretValue on that secret.
Call it with parameters, never string interpolation:
import { RDSDataClient, ExecuteStatementCommand } from "@aws-sdk/client-rds-data";
const rds = new RDSDataClient({}); // reuse across invocations: module scope
const base = {
resourceArn: process.env.CLUSTER_ARN,
secretArn: process.env.SECRET_ARN,
database: "orders",
};
export const handler = async (event) => {
const res = await rds.send(new ExecuteStatementCommand({
...base,
sql: "SELECT id, status, total FROM orders WHERE id = :id",
parameters: [{ name: "id", value: { stringValue: event.orderId } }],
formatRecordsAs: "JSON", // simpler than the column-typed default
}));
const rows = JSON.parse(res.formattedRecords ?? "[]");
return { statusCode: rows.length ? 200 : 404, body: JSON.stringify(rows[0] ?? {}) };
};
Batch where the shape allows it. BatchExecuteStatement sends one statement with many parameter sets in a single request, which is the main lever against per-statement overhead:
await rds.send(new BatchExecuteStatementCommand({
...base,
sql: "INSERT INTO order_events (order_id, type) VALUES (:id, :type)",
parameterSets: events.map((e) => ([
{ name: "id", value: { stringValue: e.orderId } },
{ name: "type", value: { stringValue: e.type } },
])),
}));
Use transactions explicitly — there is no implicit session, so a multi-statement transaction needs an identifier threaded through each call:
const { transactionId } = await rds.send(new BeginTransactionCommand(base));
try {
await rds.send(new ExecuteStatementCommand({ ...base, transactionId, sql: "UPDATE ...", parameters: [...] }));
await rds.send(new ExecuteStatementCommand({ ...base, transactionId, sql: "INSERT ...", parameters: [...] }));
await rds.send(new CommitTransactionCommand({ ...base, transactionId }));
} catch (err) {
await rds.send(new RollbackTransactionCommand({ ...base, transactionId }));
throw err;
}
Each of those is a round trip, so a three-statement transaction costs five requests. Transactions are where the Data API’s overhead is felt most.
Retry the transient errors. DatabaseResumingException in particular is expected on Aurora Serverless after a pause:
const TRANSIENT = new Set(["DatabaseResumingException", "ThrottlingException", "ServiceUnavailableException"]);
async function withRetry(fn, attempts = 5) {
for (let i = 0; ; i++) {
try { return await fn(); }
catch (err) {
if (i >= attempts - 1 || !TRANSIENT.has(err.name)) throw err;
await new Promise((r) => setTimeout(r, 200 * 2 ** i + Math.random() * 200));
}
}
}
Keep an ORM out of the path, or use one that supports it. Most ORMs assume a driver; some have Data API adapters. Hand-written SQL with parameters is the common approach, and it suits the small statement counts the model prefers anyway.
Choosing between the Data API, a proxy and a pool
The three options are not ranked; they suit different shapes of workload.
A pooled connection is the fastest per statement and supports everything the driver does. It suits long-running services and functions with bounded concurrency where instances × pool fits the budget.
RDS Proxy keeps the driver and the SQL surface but decouples client connections from database connections. It suits functions that need full SQL features at concurrency too high for direct connections, and it costs a managed component plus pinning caveats.
The Data API removes connections entirely and suits spiky, low-statement-count workloads — a webhook handler, a lightweight API, a scheduled job — especially where the Aurora DB cluster is serverless and may be paused.
| Dimension | Pooled connection | RDS Proxy | Data API |
|---|---|---|---|
| Connections per instance | 1 or more | 1 to the proxy | none |
| Per-statement latency | lowest | low | highest |
| SQL surface | full | full | subset; no session state |
| Transactions | native | native | explicit identifiers, one request each |
| Cost model | instance only | proxy hours | per request |
| Concurrency ceiling | database connections | proxy client limit | API request quota |
| Best fit | steady services | high-concurrency functions needing full SQL | spiky, few statements per call |
A common end state is a mixture: the API tier uses pooled connections through a proxy, while event handlers and cron jobs use the Data API because their concurrency is unpredictable and their statement counts are small.
Validation & verification
Connections really are zero. With the function under load, the DB cluster should show no sessions attributable to it:
SELECT application_name, count(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC;
Data API sessions appear under AWS’s own identity, not the function’s, and their count stays flat as concurrency rises.
Latency is within the estimate. Compare p50 and p99 of the function’s database time against the pooled baseline; the difference should match statements_per_invocation × per_statement_overhead.
Transactions behave. Run a failure injection that throws between two statements in a transaction and confirm the rollback request is sent and the data is unchanged.
Throttling is handled. Drive the function above the expected request rate in staging and confirm ThrottlingException is retried with backoff rather than surfacing as a user error.
Costs match the forecast. After a week, compare the Data API request count in billing with invocations × statements_per_invocation; a large discrepancy usually means more statements per invocation than assumed — often an ORM-style access pattern that should be batched.
Frequently Asked Questions
Does the Data API work with all Aurora clusters?
Can I use it from a long-running service?
What happens to prepared statements and session settings?
Is it a substitute for RDS Proxy?
Related
- Serverless Function Connection Management — the parent guide to connection strategies for functions.
- Reusing Database Connections Across Lambda Invocations — making pooled connections work where they fit.
- Limiting Database Connections from Queue-Triggered Lambda Functions — bounding concurrency when connections are used.
- AWS RDS Proxy Connection Pooling — the middle option between pools and the Data API.