AWS Aurora Serverless v2: Architecture and Performance Guide
Aurora Serverless v2 is what v1 should have been. I don’t say that lightly — I ran v1 in production for two years and spent more time fighting its scaling quirks than actually building features. The pausing, the cold starts, the inability to add read replicas. It was a product that promised serverless databases and delivered something that felt like a managed instance with extra steps.
When v2 landed, I was skeptical. AWS has a habit of slapping “v2” on things that are marginally better. But I migrated a production PostgreSQL workload from RDS provisioned to Aurora Serverless v2 last year, and it genuinely changed how I think about database scaling strategies. The scaling is fast, granular, and — this is the part that surprised me — it doesn’t drop connections when it scales. That alone makes it a different product entirely.
This is the guide I wish I’d had before that migration. Architecture, ACU tuning, performance monitoring, failover, and the cost math that actually matters.
How Aurora Serverless v2 Actually Works
Forget what you know about v1. The architecture is fundamentally different.
Aurora Serverless v2 separates compute from storage, same as provisioned Aurora. Your data lives in a distributed storage layer spanning six copies across three Availability Zones. That part hasn’t changed. What’s different is how compute scales.
Each instance — writer or reader — scales independently in Aurora Capacity Units (ACUs). One ACU is roughly 2 GiB of memory with corresponding CPU and networking. The range goes from 0.5 ACUs up to 256 ACUs depending on your engine version. Scaling happens in increments as small as 0.5 ACUs, and the larger your current capacity, the bigger the increments become. So scaling from 2 to 4 ACUs is fast, but scaling from 64 to 128 is even faster in absolute terms because the step sizes grow.
The critical difference from v1: scaling doesn’t wait for a “scaling point.” It happens while connections are open, while transactions are running, while tables are locked. There’s no pause. No disruption. No dropped connections. I’ve watched it scale mid-transaction during load tests and the application didn’t notice.
Here’s what a cluster looks like when you create one:
aws rds create-db-cluster \
--db-cluster-identifier my-aurora-cluster \
--engine aurora-postgresql \
--engine-version 15.7 \
--master-username dbadmin \
--master-user-password '<password>' \
--serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=64 \
--vpc-security-group-ids sg-0abc123def456 \
--db-subnet-group-name my-db-subnet-group
Then add a writer instance:
aws rds create-db-instance \
--db-instance-identifier my-writer \
--db-cluster-identifier my-aurora-cluster \
--engine aurora-postgresql \
--db-instance-class db.serverless
That db.serverless instance class is the magic. It tells Aurora this instance participates in serverless scaling rather than being pinned to a fixed size.
The Migration That Changed My Mind
I was running a SaaS platform on a db.r6g.2xlarge RDS PostgreSQL instance. Eight vCPUs, 64 GiB RAM, costing about $830/month in us-east-1. The workload was spiky — heavy during business hours across US and European time zones, nearly idle from midnight to 6 AM UTC. Classic candidate for serverless, right?
The migration itself was straightforward. I used pg_dump and pg_restore for a clean cutover during a maintenance window:
pg_dump -h old-rds-endpoint.rds.amazonaws.com \
-U dbadmin -d myapp -Fc -f myapp_backup.dump
pg_restore -h new-aurora-cluster.cluster-xyz.us-east-1.rds.amazonaws.com \
-U dbadmin -d myapp -Fc myapp_backup.dump
For zero-downtime migrations, AWS DMS is the better path. But we had a maintenance window and I wanted a clean break.
The first surprise came on day one. I’d set the minimum ACU to 0.5, thinking “let it scale from nothing.” Bad idea. The application’s working set — the data that needs to live in the buffer pool for decent query performance — was about 8 GiB. At 0.5 ACUs you get roughly 1 GiB of memory. Every morning when traffic ramped up, the first few minutes were brutal. Cache misses everywhere, queries that normally took 5ms were taking 200ms while the buffer pool warmed up.
I bumped the minimum to 4 ACUs (8 GiB memory) and the morning latency spikes disappeared. This is the single most important tuning decision you’ll make: set your minimum ACU high enough to hold your working set in memory. Don’t be clever about saving money here. The cost difference between 0.5 and 4 ACUs at idle is maybe $50/month. The performance difference is enormous.
aws rds modify-db-cluster \
--db-cluster-identifier my-aurora-cluster \
--serverless-v2-scaling-configuration MinCapacity=4,MaxCapacity=64
The second surprise was how fast it scaled up. During a traffic spike from a marketing campaign, I watched the ServerlessDatabaseCapacity metric go from 4 ACUs to 32 ACUs in under 30 seconds. No connection drops. No error spikes. The application just… handled it. Coming from provisioned RDS where scaling meant a multi-minute instance modification with a potential reboot, this felt like cheating.
ACU Tuning: The Numbers That Matter
Getting your ACU range right is the difference between Aurora Serverless v2 being cost-effective and being more expensive than provisioned. I’ve seen both outcomes.
Here’s how I approach it. Start by understanding your workload’s memory requirements. Connect to your PostgreSQL instance and check the buffer cache hit ratio:
SELECT
sum(heap_blks_read) as heap_read,
sum(heap_blks_hit) as heap_hit,
round(sum(heap_blks_hit) /
(sum(heap_blks_hit) + sum(heap_blks_read))::numeric * 100, 2)
as cache_hit_ratio
FROM pg_statio_user_tables;
You want that ratio above 99%. If it’s below 95%, your minimum ACU is too low. Each ACU gives you roughly 2 GiB of memory, and Aurora allocates about 75% of that to the buffer pool. So 4 ACUs gives you approximately 6 GiB of buffer cache.
For the maximum ACU, look at your peak CPU and memory usage on your current provisioned instance. If your db.r6g.2xlarge (64 GiB) peaks at 70% memory utilization, that’s about 45 GiB. Divide by 2 to get ACUs: you need a max of at least 23 ACUs. I’d round up to 32 for headroom.
Monitor the ACUUtilization metric — it tells you what percentage of your maximum capacity you’re actually using:
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name ACUUtilization \
--dimensions Name=DBInstanceIdentifier,Value=my-writer \
--start-time 2026-04-01T00:00:00Z \
--end-time 2026-04-15T00:00:00Z \
--period 3600 \
--statistics Average Maximum
If your max ACUUtilization never exceeds 50%, your maximum ACU setting is too high. That’s not costing you money directly — you only pay for what you use — but it affects max_connections. Aurora sets max_connections based on your maximum ACU value, and an unnecessarily high connection limit can mask connection pooling problems.
The ServerlessDatabaseCapacity metric shows your actual ACU consumption over time:
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name ServerlessDatabaseCapacity \
--dimensions Name=DBInstanceIdentifier,Value=my-writer \
--start-time 2026-04-01T00:00:00Z \
--end-time 2026-04-15T00:00:00Z \
--period 300 \
--statistics Average Minimum Maximum
Plot this over a week. You’ll see your workload’s actual shape. The minimum value tells you what your floor should be. The maximum tells you what headroom you need.
Performance Insights: Finding the Bottlenecks
CloudWatch metrics tell you what’s happening at the infrastructure level. Performance Insights tells you why your queries are slow. Turn it on — it’s free for 7 days of retention, and the paid tier (longer retention) is worth it for production.
The metric that matters most is DBLoad. It represents the average number of active sessions. When DBLoad exceeds your vCPU count (which scales with ACUs), you’ve got contention. Sessions are waiting.
Break it down by wait event type:
-- Check current wait events (PostgreSQL)
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND wait_event IS NOT NULL
GROUP BY wait_event_type, wait_event
ORDER BY count DESC;
Common patterns I’ve seen with Aurora Serverless v2:
IO:DataFileReaddominating wait events → your minimum ACU is too low, buffer pool can’t hold the working setLock:transactionidspikes → application-level contention, not a scaling problemCPUwait events climbing → you’re approaching your max ACU, consider raising it
The DBLoadCPU and DBLoadNonCPU CloudWatch metrics from Performance Insights are gold for capacity planning. If DBLoadNonCPU is consistently high, throwing more ACUs at the problem won’t help — you’ve got I/O or lock contention that needs query optimization.
I keep a CloudWatch dashboard with these four metrics side by side for every Aurora Serverless v2 cluster:
aws cloudwatch put-dashboard --dashboard-name aurora-perf \
--dashboard-body '{
"widgets": [
{"type":"metric","properties":{"metrics":[
["AWS/RDS","ServerlessDatabaseCapacity","DBInstanceIdentifier","my-writer"],
["AWS/RDS","ACUUtilization","DBInstanceIdentifier","my-writer"],
["AWS/RDS","DBLoad","DBInstanceIdentifier","my-writer"],
["AWS/RDS","CPUUtilization","DBInstanceIdentifier","my-writer"]
],"period":300,"stat":"Average","region":"us-east-1","title":"Aurora Serverless v2"}}
]
}'
Failover Strategy: Promotion Tiers Are Everything
Aurora’s failover story is strong, but with Serverless v2 there’s a subtlety that catches people. Reader instances in promotion tiers 0 and 1 scale in lockstep with the writer. Readers in tiers 2-15 scale independently.
This matters enormously for failover. If your writer is running at 32 ACUs and your reader is in tier 2 sitting at 4 ACUs because it’s only handling light read traffic, a failover promotes that 4-ACU reader to be the new writer. It’ll scale up, but there’s a window where your new writer is undersized for the write workload. I’ve seen this cause 10-15 seconds of elevated latency post-failover.
The fix: put at least one reader in tier 0 or 1.
aws rds create-db-instance \
--db-instance-identifier my-reader-failover \
--db-cluster-identifier my-aurora-cluster \
--engine aurora-postgresql \
--db-instance-class db.serverless \
--promotion-tier 1
aws rds create-db-instance \
--db-instance-identifier my-reader-cheap \
--db-cluster-identifier my-aurora-cluster \
--engine aurora-postgresql \
--db-instance-class db.serverless \
--promotion-tier 10
The tier-1 reader mirrors the writer’s capacity — ready for instant failover. The tier-10 reader scales independently, keeping costs low for read offloading. Put them in different AZs for proper high availability.
Test your failover. Regularly.
aws rds failover-db-cluster --db-cluster-identifier my-aurora-cluster
With a tier-0 or tier-1 reader available, failover typically completes in under 30 seconds. Without one, Aurora has to create a new writer instance, and you’re looking at minutes. That’s the difference between a blip and an incident.
For cross-region disaster recovery, Aurora Global Database works with Serverless v2. The secondary clusters can run at minimum ACUs until needed, keeping your DR costs low while maintaining sub-second replication lag. I wrote about this pattern in the context of designing scalable AWS architectures.
The Cost Math: When Serverless v2 Wins (and When It Doesn’t)
Let’s be honest about costs. Aurora Serverless v2 charges per ACU-hour. In us-east-1, that’s roughly $0.12/ACU-hour for PostgreSQL. A single ACU running 24/7 for a month costs about $87.
Compare that to a provisioned db.r6g.large (2 vCPUs, 16 GiB): roughly $200/month on-demand. That’s equivalent to about 8 ACUs of memory. If your serverless instance averages 8 ACUs around the clock, you’d pay ~$696/month. Provisioned wins by a mile.
But workloads aren’t constant. That SaaS platform I migrated? It averaged 6 ACUs during business hours (14 hours/day) and 2 ACUs overnight. The monthly math:
- Business hours: 6 ACUs × 14 hrs × 30 days × $0.12 = $302
- Off hours: 2 ACUs × 10 hrs × 30 days × $0.12 = $72
- Total: ~$374/month
The provisioned db.r6g.2xlarge it replaced cost $830/month. Even adding a reader for failover (another ~$200/month for the tier-1 reader averaging 4 ACUs), total came to ~$574. Still a 31% savings.
The breakeven point depends on your variability ratio. If your peak-to-trough ratio is less than 2:1, provisioned with Reserved Instances will probably be cheaper. If it’s 3:1 or higher, Serverless v2 starts winning. At 5:1 or above, it’s not even close.
For a deeper dive into the broader cost picture, I’ve covered strategies in AWS cost optimization and cloud cost optimization that apply here too.
Mixed-Configuration Clusters: The Best of Both
One thing I don’t see discussed enough: you can mix provisioned and serverless instances in the same cluster. This is powerful for workloads where the write pattern is predictable but reads are spiky.
Run a provisioned writer (cheaper for steady-state) with Serverless v2 readers that scale with demand. Or flip it — a Serverless v2 writer for variable write loads with provisioned readers for predictable analytics queries.
# Provisioned writer for steady workload
aws rds create-db-instance \
--db-instance-identifier my-provisioned-writer \
--db-cluster-identifier my-aurora-cluster \
--engine aurora-postgresql \
--db-instance-class db.r6g.xlarge
# Serverless reader for spiky reads
aws rds create-db-instance \
--db-instance-identifier my-serverless-reader \
--db-cluster-identifier my-aurora-cluster \
--engine aurora-postgresql \
--db-instance-class db.serverless
I’ve used this pattern for a reporting workload where the writer handled steady OLTP traffic and the readers absorbed unpredictable dashboard queries. The provisioned writer on a Reserved Instance kept base costs low, while the serverless readers scaled from 2 to 48 ACUs during month-end reporting without anyone paging me.
What I’d Do Differently
If I were starting the migration over, three things:
-
Start with minimum ACU at 4, not 0.5. The cold buffer pool problem cost us a week of debugging morning latency. Your working set needs to stay warm.
-
Set up the CloudWatch dashboard before migration, not after. I was flying blind for the first 48 hours because I hadn’t configured the serverless-specific metrics.
ServerlessDatabaseCapacityandACUUtilizationshould be on your primary dashboard from minute one. -
Use RDS Proxy from the start. Connection management with serverless scaling is different.
max_connectionsis tied to your maximum ACU setting, but the actual available connections scale with current capacity. RDS Proxy smooths this out and reduces failover time to single-digit seconds.
Aurora Serverless v2 isn’t perfect. The per-ACU-hour pricing means you need to actually do the math for your workload — it’s not automatically cheaper. The scaling, while fast, still has a brief warm-up period for the buffer pool when scaling up significantly. And the data consistency model is the same as provisioned Aurora, which means you still need to think about replication lag to readers.
But for variable workloads? It’s the best managed database option AWS offers right now. The gap between v1 and v2 is the gap between a proof of concept and a production-ready service. If you tried v1 and walked away disappointed, give v2 an honest look. It earned my trust the hard way — by not waking me up at 3 AM.