Testing the Write and Read Performance of Aurora DSQL from AWS Lambda

Testing the Write and Read Performance of Aurora DSQL from AWS Lambda

We measured connections from Lambda to Aurora DSQL in various configurations: latency differences with parallel execution and multi-Region setups, and practical connection management to work around the 100 connections/sec limit.
2026.03.09

This page has been translated by machine translation. View original

Aurora DSQL is AWS's new serverless distributed database. To understand what performance characteristics it shows under real workloads, I measured write and read performance using AWS Lambda.

This article examines the effects of concurrency and physical distance between regions (cross-region), as well as the performance difference between reusing and not reusing DB connections. I also tried to verify the write performance trade-off between single-region and multi-region configurations.

1. Test Environment

Item Value
Lambda runtime Node.js 22.x / ARM64 (Graviton)
Lambda memory 1,769MB (equivalent to 1 vCPU)
Lambda region ap-northeast-1 (Tokyo)
DSQL clusters Single-region clusters in ap-northeast-1 (Tokyo) and us-west-2 (Oregon), and a multi-region cluster of Tokyo + Seoul (witness: Osaka)
Parallel execution platform Step Functions Map state (concurrency controlled by MaxConcurrency)
Connector @aws/aurora-dsql-postgresjs-connector

The test table is defined as follows.

CREATE TABLE perf_test (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  data TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

2. Least-Privilege Design Using IAM Roles

The Step Functions role only has permission to invoke Lambda, and the Lambda role only has permission to connect to DSQL.

Lambda Execution Role Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "dsql:DbConnect",
      "Resource": [
        "arn:aws:dsql:ap-northeast-1:<account-id>:cluster/<cluster-id-tokyo>",
        "arn:aws:dsql:us-west-2:<account-id>:cluster/<cluster-id-oregon>"
      ]
    }
  ]
}

dsql:DbConnect is the action that allows connecting as a regular user. Limiting Resource to the ARNs of specific clusters also prevents connections to other clusters.

Linking the DB Role and IAM

-- Connect as admin and run
CREATE ROLE bench_user WITH LOGIN;
AWS IAM GRANT bench_user TO 'arn:aws:iam::<account-id>:role/<lambda-role-name>';
GRANT USAGE ON SCHEMA public TO bench_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO bench_user;

AWS IAM GRANT is DSQL-specific syntax that links an IAM role ARN with a DB role. This setting is required for each cluster (in a multi-region configuration, it is propagated automatically). GRANT ... ON ALL TABLES is only effective for tables that exist at the time of execution. Since DSQL does not support ALTER DEFAULT PRIVILEGES, GRANT had to be run again whenever a table was added.

3. Lambda Function Implementation

import { auroraDSQLPostgres } from "@aws/aurora-dsql-postgresjs-connector";

const ENDPOINTS = {
  "ap-northeast-1": process.env.DSQL_ENDPOINT_TYO,
  "us-west-2": process.env.DSQL_ENDPOINT_PDX,
};

let cachedSql = null;
let cachedRegion = null;

function getSql(region) {
  if (cachedSql && cachedRegion === region) return cachedSql;
  cachedSql = auroraDSQLPostgres({
    host: ENDPOINTS[region],
    username: "bench_user",
  });
  cachedRegion = region;
  return cachedSql;
}

export const handler = async (event) => {
  const op = event.op || "insert";
  const region = event.region || "ap-northeast-1";
  const cache = event.cache !== false;

  const start = Date.now();
  const sql = cache
    ? getSql(region)
    : auroraDSQLPostgres({ host: ENDPOINTS[region], username: "bench_user" });

  try {
    if (op === "insert") {
      const id = crypto.randomUUID();
      await sql`INSERT INTO perf_test (id, data) VALUES (${id}, ${"bench"})`;
    } else if (op === "select") {
      await sql`SELECT id, data FROM perf_test LIMIT 1`;
    }
    const ms = Date.now() - start;
    if (!cache) await sql.end();
    return { ok: true, op, region, cache, ms };
  } catch (e) {
    const ms = Date.now() - start;
    if (!cache) try { await sql.end(); } catch {}
    return { ok: false, op, region, cache, ms, error: e.message?.slice(0, 200) };
  }
};

cachedSql is kept in a global variable so the connection is reused on warm starts. Because the official connector transparently generates and refreshes IAM authentication tokens, there is no need to manage token expiration when reusing connections.
For comparison, I also prepared an implementation that connects every time when false is specified for event.cache.

4. Effect of Connection Reuse

Concurrency 1

This is a comparison between no reuse (new DB connection each time) and reuse (DB connection kept).

Pattern Without reuse With reuse
Tokyo→Tokyo INSERT 222ms 21ms
Tokyo→Tokyo SELECT — 6ms
Tokyo→Oregon INSERT 1,512ms 106ms
Tokyo→Oregon SELECT — 102ms

Without reuse, a TCP/TLS handshake and IAM token generation occur every time. Especially in cross-region cases, the RTT is large, so the impact is amplified by the number of round trips in the handshake, and the Tokyo→Oregon INSERT takes 1,512ms. With reuse, the Tokyo→Tokyo INSERT drops to 21ms, and even Oregon drops to 102–106ms, which almost matches the pure RTT.

Parallel Execution (Without Reuse)

This configuration runs Lambdas in parallel with a Step Functions Map state, with each Lambda connecting to DSQL and performing an operation once.

Tokyo→Tokyo

Concurrency op med(ms) p95(ms) rps
50 INSERT 162 612 28.3
100 INSERT 105 518 44.7
200 INSERT 95 263 48.7
50 SELECT 88 118 53.8
100 SELECT 88 143 48.8
200 SELECT 90 133 53.4

Without reuse, rps plateaus at around 50.

Tokyo→Oregon

Concurrency op med(ms) p95(ms) rps
50 INSERT 1,338 1,473 21.9
100 INSERT 1,188 1,473 24.3
200 INSERT 1,165 1,408 27.3
50 SELECT 1,064 1,301 24.4
100 SELECT 1,062 1,302 27.8
200 SELECT 1,057 1,125 28.3

For Oregon, the Tokyo–Oregon RTT (about 100ms) multiplied by the number of handshake round trips is added, pushing med above 1,000ms.

Parallel Execution (With Reuse)

Next are the results with connection reuse.

Tokyo→Tokyo

Concurrency op med(ms) p95(ms) rps
50 INSERT 10 12 48.4
100 INSERT 10 12 58.1
200 INSERT 9 11 63.3
50 SELECT 4 5 64.0
100 SELECT 4 5 63.9
200 SELECT 4 5 66.4

Tokyo→Oregon

Concurrency op med(ms) p95(ms) rps
50 INSERT 106 1,487 36.4
100 INSERT 106 107 62.3
200 INSERT 105 107 60.0
50 SELECT 100 102 62.3
100 SELECT 100 102 74.5
200 SELECT 100 101 69.6

With connection reuse, Tokyo→Tokyo INSERT improved to a med of 9–10ms and SELECT to 4ms. Oregon also dropped to a med of 100–106ms, which is pure RTT only.

5. Write Throughput

A single Lambda sequentially INSERTed 1,000 rows, and I ran this while varying the concurrency. The configuration is Tokyo→Tokyo with connection reuse. Each concurrency level was run sequentially to eliminate interference from simultaneous execution.

Concurrency Total rows wall(s) Total rps Single Lambda rps
1 1,000 9.7 103 105
10 10,000 10.0 1,002 111
20 20,000 9.8 2,048 113
30 30,000 9.8 3,065 117
40 40,000 10.2 3,905 116
50 50,000 10.0 4,996 116
100 100,000 20.0 5,000 117

INSERT throughput of a single Lambda is stable at about 115 rps, and it scales almost linearly with parallelization. It reached about 5,000 rps at 50 concurrency. The total rps not growing at c=100 is due to internal batch splitting in the Step Functions Map state, and the DSQL-side limit has not been reached.

6. Single vs Multi-Region

I checked the performance difference for INSERT / SELECT between single-region and multi-region (Tokyo + Seoul, witness: Osaka). I created two sets of clusters and measured each to confirm reproducibility. Lambda connects from Tokyo to the Tokyo endpoint.

Cluster creation time

Configuration Time required
Single-region About 8 seconds
Multi-region About 100–115 seconds

Multi-region requires 3 steps (creating a cluster in each region + mutual peering), and it takes about 2 minutes to become ACTIVE.

INSERT (with reuse, Tokyo→Tokyo)

Concurrency Single med(ms) Multi med(ms) Ratio
50 10 65 6.5x
100 10 64 6.4x
200 9 64 7.1x

SELECT (with reuse, Tokyo→Tokyo)

Concurrency Single med(ms) Multi med(ms) Ratio
50 4 4 1.0x
100 4 4 1.0x
200 4 4 1.0x

INSERT became about 6–7 times slower with multi-region. This is because, to guarantee strong consistency, synchronization overhead between regions occurs at commit time in multi-region. On the other hand, SELECT completes within the local region, so there is no difference.

7. Summary

This was a small-scale test using simple INSERT / SELECT against a single table, but it clarified DSQL's performance characteristics and the key points when using it from a serverless environment.

The most important point is reusing DB connections. With reuse, INSERT takes 10ms or less and SELECT takes 4–6ms, a 10–20x improvement compared to connecting every time. I was also able to confirm that write throughput scales almost linearly from a single Lambda (about 115 rps) to 5,000 rps at 50 concurrency.

In addition, in a multi-region configuration, INSERT becomes about 6–7 times slower due to the synchronization overhead that guarantees strong consistency. On the other hand, SELECT performance, which is a local read, does not change. Be careful about the trade-off between availability and write performance.

An important caveat is DSQL's connection rate limit (100 connections/second, a hard limit that cannot be relaxed). When putting this into production from Lambda, connection reuse should be a prerequisite, and to avoid a concentration of cold starts during spikes, using Provisioned Concurrency and controlling concurrency are essential.

In cases where high throughput is required in multi-region or cross-region setups, it is important to devise ways to reduce direct access to DSQL. For writes, asynchronous bulk processing that buffers with SQS or similar is effective. For reads, since you cannot rely on read replicas, please consider a cloud-native overall design, such as application-side caching or page caching with CloudFront.

References

Share this article

AWSのお困り事はクラスメソッドへ