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.

Pooled connections against the Data API With pooled connections each function instance holds a connection to the DB cluster. With the Data API, instances call an HTTPS endpoint that maintains its own connections, so instance count does not affect the database. Pooled connections instance 1 instance 2 instance N DB cluster N connections Data API instance 1 instance 2 instance N Data API HTTPS DB cluster few connections the right-hand model has no connection to exhaust, and one round trip more per statement
The Data API replaces the connection with an HTTPS call, so instance count stops mattering — at the cost of a round trip through a service on every 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
Added latency by statements per invocation At four milliseconds of overhead per statement, an invocation issuing two statements adds eight milliseconds, one issuing five adds twenty, and one issuing twenty adds eighty. Latency added per invocation 2 statements: 8 ms 5 statements: 20 ms 20 statements: 80 ms batch, or use a pooled connection
The overhead is per statement, so the model suits functions that issue few statements. Twenty statements per invocation multiply the difference by twenty.

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.

Choosing a connection model for functions A pooled connection gives the lowest latency and full features but consumes connections; RDS Proxy keeps features while bounding connections; the Data API removes connections at the cost of latency and feature surface. Pooled lowest latency full SQL connections scale with instances RDS Proxy low latency full SQL bounded connections Data API highest latency subset of SQL no connections Shape of the workload decides mixed estates are normal: a proxy for the API tier, the Data API for event handlers
The three models trade connection pressure against per-statement latency and feature surface. The right choice follows the workload's shape, not a preference.

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?
It is available for specific Aurora engine versions and cluster types, and the details have changed over time — including support for provisioned clusters rather than only Serverless. Check the current documentation for the engine and version in use before designing around it.
Can I use it from a long-running service?
You can, but there is little reason to: a long-running service can hold a pool, which is faster and cheaper per statement. The API’s advantage is specifically for callers that cannot hold connections.
What happens to prepared statements and session settings?
There is no session, so neither persists. Every statement is independent, which is also why transaction identifiers must be threaded explicitly.
Is it a substitute for RDS Proxy?
Only for workloads that fit its constraints. Where full SQL features, driver behaviour or low per-statement latency matter, the proxy is the better decoupling layer — see Choosing a Connection Proxy for Serverless Postgres.