ServerlessDatabaseCapacity sitting flat near max_capacity while pg_stat_activity is clean almost always means one of two things: a min_capacity or max_capacity change someone made in the console and never reverted, or a logical replication slot whose restart_lsn has stopped advancing. It is almost never a runaway query. Check the scaling configuration first, because that is one API call and it rules out half the problem. Then read pg_replication_slots, before you restart or drop anything.
Problem signals:
- ServerlessDatabaseCapacity is a flat line for days where it used to be a sawtooth, with no matching rise in DatabaseConnections
- Cost Explorer shows Aurora:ServerlessV2Usage stepping up on one calendar day and holding, while every other RDS usage type is flat
- Capacity at 04:00 UTC is the same number as capacity at 14:00 UTC
- pg_stat_activity has nothing older than 60 seconds and pg_stat_progress_vacuum returns zero rows
- pg_replication_slots shows an active slot with hundreds of GB between restart_lsn and pg_current_wal_lsn()
What ServerlessDatabaseCapacity flat near max_capacity actually costs
218 hours at 148 ACU and not one page
A claims-analytics platform we work with runs one Aurora PostgreSQL Serverless v2 cluster in us-east-1: a writer and a single reader, roughly 15 services in front of it. For 218 hours the pair sat at a combined 148 ACU. The writer floated between 96 and 128, the reader between 32 and 48. Nothing paged. Latency was fine, the on-call rotation had a quiet week, and the only artifact of the whole thing was a CloudWatch chart that had gone flat where it used to be a sawtooth.
At the us-east-1 list rate of $0.12 per ACU-hour, 148 ACU-hours per hour for 218 hours is $3,872 of capacity. The same window at that cluster's normal 6.4 ACU average would have been $167. The gap was found by a monthly close-out preview, not by anything in the observability stack, which is the part worth sitting with. This article assumes Aurora PostgreSQL 15.x on Serverless v2, AWS CLI v2, and that you have psql access to the writer.
The flat line is most of the diagnosis. Aurora Serverless v2 scales up in seconds and comes down gradually, because shrinking capacity means giving back memory that the buffer cache is holding. A cluster with a genuinely quiet overnight window draws a sawtooth. A cluster that draws a flat line at or near its ceiling is either being told to stay there, or being kept from leaving. Those are two different bugs with two different fixes, and they are frequently both present at once.
aws ce get-cost-and-usage \
--time-period Start=2026-07-15,End=2026-08-01 \
--granularity DAILY \
--metrics UnblendedCost UsageQuantity \
--filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon Relational Database Service"]}}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE
The dollar total is smoothed by every other RDS instance in the account. The Aurora:ServerlessV2Usage line is not. In regions other than us-east-1 the usage type carries a region prefix.
Run that before you run anything on the database, because it dates the step change to a single day. Knowing the cluster went from 6 to 148 ACU-hours per hour on a Tuesday morning and never came back is worth more than an hour of query analysis. It converts an open-ended performance question into "what happened on that day", and somebody's memory usually answers it in about four minutes.
Which three causes pin Aurora Serverless v2 at high capacity?
pg_stat_activity was clean, which is the useful part
We rank these by how often we actually find them, not by how interesting they are. The first one accounts for more of these calls than the other two combined, and it is the one nobody wants to check because it feels too dumb to be the answer.
| Step | What it does |
|---|---|
| 1. A scaling change made in the console and never reverted | Someone raised max_capacity for a load test, a migration cutover, or a seasonal peak, and raised min_capacity at the same time so the cluster would not warm up between iterations. The work finished, the revert became a mental note, and the mental note lost to an afternoon incident. On the cluster above, min_capacity had gone from 2 to 16 on both instances. That floor alone bills $80.64 a day whether anyone queries the database or not. |
| 2. A logical replication slot whose restart_lsn stopped advancing | A CDC connector, a DMS task, or a hand-rolled pglogical consumer holds a slot open permanently. That is normal. What is not normal is restart_lsn standing still: the cluster retains every WAL segment behind it, the walsender stays attached, and the writer never reaches the quiet state that scale-down needs. This one hides well because the consumer's own health check is green. |
| 3. A resident working set plus a job that never lets it go idle | A rollup that runs every five minutes and briefly touches a large table will hold the buffer cache warm and reset the scale-down evaluation before it completes. Capacity ratchets up on each burst and never gets a long enough gap to come down. You see this as a capacity floor that is high but not at the ceiling, with a saw-tooth of a few ACU riding on top of it. |
The two things teams reach for instead are a runaway query and a connection leak. Both are real failure modes and both are visible in a single query, so spend the ninety seconds and rule them out: if nothing in pg_stat_activity is older than 60 seconds, if the connection count matches last month, and if pg_stat_progress_vacuum is empty, stop looking at the workload. The other reflex is to blame the service, as in "Serverless v2 just does not scale down properly". It scales down. Something on this cluster is asking it not to, and the next section tells you which.
The check that separates a console change from a stuck replication slot
Two calls, ninety seconds, and the branch is decided
# 1. What is the ceiling, and what is the floor you pay for around the clock?
aws rds describe-db-clusters \
--db-cluster-identifier claims-analytics-prod \
--query 'DBClusters[0].ServerlessV2ScalingConfiguration'
# 2. Is anything holding WAL? Run this on the writer.
SELECT slot_name,
plugin,
slot_type,
active,
active_pid,
restart_lsn,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
One call returns the ceiling you set. One query returns the thing that ignores it.
The first call returns a small object with MinCapacity and MaxCapacity. If MinCapacity reads 16 and your Terraform says 2, you are done with half the investigation: that is your floor, you have been paying it every hour since it changed, and CloudTrail will tell you who set it and when. Do not stop there, though. A raised floor explains a floor. It does not explain a writer sitting at 128.
The second query is the one that decides the rest. On this cluster it returned exactly one row: a logical slot on the pgoutput plugin, active true, with a live active_pid, and retained_wal of 241 GB. The number by itself is not proof of anything, because a healthy consumer can be legitimately behind during a backfill. Run the query twice, sixty seconds apart, and compare restart_lsn itself rather than the size sitting beside it. retained_wal is a distance from a moving target: pg_current_wal_lsn() advances with every write, so a constant retained_wal means restart_lsn is advancing at exactly the write rate, which is a consumer holding a steady lag, not a stuck one. The stuck condition is restart_lsn that has not moved at all between the two samples, and retained_wal growing is its confirming symptom. Falling means the consumer is catching up and what you have is a throughput problem.
Two branches, and the common case is that both fire. Fixing only the scaling config leaves the cluster pinned and makes it look like the fix failed.
Here is the part a generic answer misses. A slot can be stuck while the connector is entirely healthy, because restart_lsn only advances when the consumer commits an offset for a change it captured. If the tables in the publication are quiet while the rest of the database is hot, the connector has nothing to commit, so it never acknowledges any position, so the cluster keeps every WAL segment written since the last real event. Every dashboard is green. Every health check passes. The slot has been standing still for nine days. We check the captured tables' write rate against the cluster's total write rate whenever the two look decoupled, and it is the fastest way to catch this.
How do you bring capacity down without forcing a CDC re-snapshot?
Lower the ceiling before you go anywhere near the slot
Capture before you change. A writer reboot or a failover clears the buffer cache and resets pg_stat_statements, which destroys the memory-pressure evidence and the query history you will want if the capacity climbs back. Dropping the slot destroys the lag measurement that proves what happened. Before any mutation, save the ServerlessDatabaseCapacity series for the past 14 days, the full output of the slot query, a snapshot of pg_stat_statements ordered by total_exec_time (the column is total_exec_time on PostgreSQL 13 and later), and the CloudTrail event for the scaling change if there is one.
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name ServerlessDatabaseCapacity \
--dimensions Name=DBInstanceIdentifier,Value=claims-analytics-prod-writer \
--start-time 2026-07-07T00:00:00Z \
--end-time 2026-07-21T00:00:00Z \
--period 3600 \
--statistics Average Maximum \
--output json > capacity-before.json
Two weeks of hourly capacity, on disk, before anything changes. Note where the window ENDS: on the step-change day Cost Explorer already handed you, 2026-07-21 here. Run it through to today instead and the Maximum series hands back the pinned value you are trying to remove, 128, rather than the ceiling the cluster genuinely used before the change.
The scaling configuration comes down first, because it is the cheapest and most reversible move you have. It is a soft limit that takes effect at the next capacity evaluation and restarts nothing. Set max_capacity to a number the cluster actually reached before the change, which is the Maximum in capacity-before.json now that the window stops at the step change, and not to a guess. Here that Maximum read 32, and capacity fell from 112 to 32 within a few minutes with no query errors and a small, settled rise in p99.
aws rds modify-db-cluster \
--db-cluster-identifier claims-analytics-prod \
--serverless-v2-scaling-configuration MinCapacity=2,MaxCapacity=32 \
--apply-immediately
# Then land the same values in code so the next console edit reads as drift.
resource "aws_rds_cluster" "claims_analytics" {
# ...
serverlessv2_scaling_configuration {
min_capacity = 2
max_capacity = 32
}
}
The CLI stops the bleeding in one call. The Terraform block is what stops the same person doing it again next quarter.
The slot is where people cause real damage, so go slowly. Postgres will refuse to drop a slot a consumer is attached to, and that refusal is a favour: dropping it forces most CDC connectors to re-snapshot the source tables, which on a 180 GB dataset is a multi-hour outage for everything downstream.
ERROR: replication slot "cdc_claim_events" is active for PID 24817
Read this as a warning, not an obstacle. The slot is load-bearing for a pipeline somebody owns.
Work in this order instead. Confirm who owns the consumer and whether it is running. If it is running and behind, let it catch up and re-measure; a backfill that finishes takes the pressure off by itself. If it is running but restart_lsn is not moving, the offset commit is the problem, and the fix is to make the connector emit something to acknowledge even when the captured tables are quiet. Debezium's PostgreSQL connector exposes a heartbeat interval and a heartbeat action query for exactly this (heartbeat.interval.ms and heartbeat.action.query); check the property names against the connector version you run, and point the action query at a small scratch table. One requirement decides whether this works at all: on pgoutput, decoding only emits changes for tables in the publication, so a scratch table outside it generates WAL and produces no decoding output, the connector still has nothing to acknowledge, and the slot does not move. Add it first, ALTER PUBLICATION <name> ADD TABLE <scratch>, then watch restart_lsn advance. Skip that and the fix looks like it failed when it was never wired up. A controlled pause and resume, after verifying the connector has flushed its offsets, is the safe way to nudge a slot that has drifted. Dropping the slot is the last option, taken deliberately, with the re-snapshot scheduled.
Confirm the fix on the shape of the curve, not on a single reading. Watch one full traffic cycle: the number you care about is the off-peak floor, not the peak. On this cluster the writer settled to 3 ACU at 04:00 UTC and rode up to 12 ACU during the business-hours ramp, which is a sawtooth again, and the day's total came to $19. Check that against the baseline rather than against relief: with the reader back on its restored floor of 2, the pair averages about 6.6 ACU, which is the 6.4 this cluster ran at before any of this started. A day that lands at $47 is 16 ACU average and still nearly three times baseline, and it will feel like success because it is so much better than $426. If the off-peak floor still matches the daytime peak, one of the two causes is still live and you have fixed the visible one.
- Alarm on ServerlessDatabaseCapacity itself: average above roughly twice your known peak for 6 consecutive hourly datapoints. On this cluster that threshold would have paged inside a day instead of at monthly close.
- Watch the scaling configuration, not just the capacity. An EventBridge rule matching CloudTrail's ModifyDBCluster events from aws.rds, routed to a channel with the calling IAM identity in the message, turns a silent console edit into a named change in minutes rather than at the next nightly plan.
- Export retained WAL bytes per slot from the writer as a custom metric on a one-minute schedule. It is the single number that predicts this bill, and it is cheap to ship.
- PostgreSQL 13 and later expose max_slot_wal_keep_size, which invalidates a slot once its retained WAL passes the limit. Confirm whether your Aurora parameter group exposes it before you plan around it, and be clear with the pipeline owner that invalidation means a re-snapshot.
- Give temporary parameter overrides an expiry. A short-lived branch with an expiry date and a scheduled job that opens the revert PR the next day costs almost nothing and closes the exact hole that produced this.
One control worth more than the rest: if the drift alert exists and nobody sees it, it does not exist. On this engagement the nightly drift plan had been running the whole time and posting into a channel that had been muted weeks earlier after a noisy false-positive run. Muting a channel to silence one bad alert is how a working detection system becomes decoration. When we audit a cost incident we now check which alerting channels have received zero human reads recently, and it turns up more than it should. We have written about the wider pattern in our work on cloud cost spikes.
Aurora Serverless v2 scale-down questions we get asked next
What people type into search right after this one
Short answers to the follow-ups that arrive within an hour of the first fix.
- Does a high max_capacity cost anything if the cluster never reaches it? You are billed for ACUs in use, with min_capacity as the floor, so the ceiling itself is not the charge. The risk is that a high ceiling removes the guardrail that would have capped the climb, which is precisely what happened here.
- Can I just drop the replication slot to make capacity fall? Yes, and capacity will fall. Most CDC consumers will then re-snapshot the source tables, so treat it as a scheduled outage for the downstream pipeline rather than a quick fix. Confirm the owner first.
- Will a failover or a writer reboot clear this? It will reset capacity for a while and it will clear your buffer cache and your pg_stat_statements history, which is evidence you want. The slot backlog is not cleared by a failover, so if the slot is the cause, the capacity climbs straight back.
- Does any of this apply to Aurora MySQL Serverless v2? The scaling behaviour and the console-drift failure mode carry over directly. The replication mechanism does not: on MySQL the equivalent thing to look at is binlog retention and whoever is consuming it.
- What min_capacity should we set? We have stopped setting min_capacity above 4 ACU on clusters with a genuinely quiet overnight window. The honest cost of that position: the first minute of the morning ramp runs against a cold cache, and on this cluster that read as roughly 23ms of extra p99 before it settled. Against $80.64 a day for a floor nobody uses, we take the 23ms.
- Why did capacity stay high even after the connector caught up? Scale-down is gradual by design, and it is bounded by memory that the buffer cache is still holding. Give it a full off-peak window before you conclude the fix did not work.
If you arrived here because a CDC pipeline you inherited is doing something you cannot explain, the replication slot is usually the least documented part of the whole system. We cover that ground in our migration recovery work, where a half-finished cutover leaving a live slot behind is one of the more common things we walk into.
When the ACU line is flat and the invoice is not waiting for you
If the cycle closes before the capacity comes down
The hard version of this is not the diagnosis. It is the moment you have found a 241 GB slot, the pipeline that owns it belongs to a team that is mid-sprint on something else, the billing cycle closes in 36 hours, and the safe fix and the fast fix are not the same fix. Getting that call right needs someone who has seen both outcomes: the controlled restart that costs 78 seconds, and the slot drop that costs a six-hour re-snapshot and an apology to three downstream consumers.
We do this work on live Aurora clusters with the pipeline owners in the room, and we care as much about the guardrail you land afterwards as about the number on the dashboard tonight. Most of these engagements end with two alarms, one Terraform PR, and a slot-lag metric that nobody had before. If your capacity chart has been flat for days and the close is coming, book an infrastructure review and we will be on a call with you the same day to work the two branches in order.
Originally published at https://infraforge.agency/insights/aurora-serverless-v2-not-scaling-down/.
If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — see /review.
Top comments (0)