Cloud Database Trade-offs: RDS vs Aurora vs DynamoDB vs ElastiCache
The most consequential architectural decision in a typical cloud application is not which container orchestrator to use or how to structure the microservices—it is which database. The choice is largely irreversible: data migrations are expensive, query patterns calcify around the original data model, and the database’s characteristics (consistency model, scaling approach, query language) shape every layer of the application above it.
AWS offers four distinct managed database products that cover most use cases: RDS for conventional relational workloads, Aurora for high-throughput relational workloads, DynamoDB for key-value and document access patterns at any scale, and ElastiCache for in-memory caching and data structures. Each has a different cost model, scaling story, operational profile, and failure mode.
This post covers what each product actually does under the hood, where it wins, where it loses, and how to choose between them deliberately rather than by default.
RDS: Conventional Relational Databases, Managed
Amazon RDS wraps conventional database engines—PostgreSQL, MySQL, MariaDB, SQL Server—in AWS’s managed infrastructure. AWS handles OS patching, storage provisioning, backups, and Multi-AZ replication. You pick an instance type, get a connection endpoint, and run SQL.
Multi-AZ Replication
RDS Multi-AZ maintains a synchronous standby in a second Availability Zone. Every write to the primary is synchronously committed to the standby before the write acknowledgment returns to the application. This means RPO is zero—no committed transactions are lost during a failover.
Failover is automatic but not instantaneous. The detection cycle, promotion of the standby, and DNS propagation takes 60–120 seconds with the traditional single-standby Multi-AZ mode. AWS’s Multi-AZ Cluster option (PostgreSQL and MySQL) uses a two-readable-standby model and achieves sub-35-second failover with zero data loss—still not zero, but closer to Aurora’s characteristics.
During failover, the primary endpoint’s DNS record is updated to point to the promoted standby. Applications using persistent connections need to handle reconnection. Applications that validate the connection before each query reconnect automatically.
Read Replicas
RDS supports up to 15 read replicas per instance, including up to 5 cross-region replicas. Replication to read replicas is asynchronous—there is replication lag, typically seconds but potentially more under high write load. Reads from replicas may return stale data. For workloads where eventual consistency is acceptable on reads (reporting, analytics, non-critical lookups), this is appropriate. For any read that must see its own prior write, route it to the primary.
Connection Limits and RDS Proxy
The connection limit problem is the most frequently underestimated RDS scaling constraint. PostgreSQL allocates a backend process per connection, each consuming approximately 5–10 MB of memory. On a db.t3.medium (4 GB RAM), max_connections defaults to around 60. On a db.r6i.xlarge (32 GB RAM), you might get 2,000–3,000—but this is not a hard number. High work_mem settings for complex queries multiply memory consumption per connection significantly.
The connection exhaustion pattern: traffic spikes, new application instances launch, each instance tries to establish a connection pool, the database runs out of connections, new queries fail with “too many connections,” and the traffic spike cascades into an outage from connection failure rather than query load.
RDS Proxy solves this by sitting between your application and the database, multiplexing many application connections to a smaller pool of database connections. The proxy maintains warm database connections, reuses them across application requests, and returns “sorry, limit exceeded” to applications rather than exhausting the database.
Application pods (50 × 20 connections = 1,000 connections)
│
▼
RDS Proxy (maintains 50 connections to database)
│
▼
RDS PostgreSQL (max_connections = 100)
RDS Proxy costs approximately $0.015 per vCPU-hour with a minimum of 2 vCPUs—roughly $22/month minimum, scaling with the underlying instance size. It is worth it when:
- You are running Lambda functions that create new connections per invocation (Lambda cannot maintain a persistent connection pool)
- Your application is containerized and autoscales, creating variable connection counts
- You have connection exhaustion incidents in production
It is not worth it when:
- Your application already uses an efficient connection pooler at the application layer (PgBouncer in transaction mode)
- You have a small number of long-lived connection pools that never exhaust the limit
- Sub-millisecond latency is critical (Proxy adds approximately 1ms per hop)
Connection pinning: RDS Proxy multiplexes connections when possible, but some session-level operations force a connection to pin to a specific client for the duration of the session: advisory locks, SET LOCAL statements, prepared statements across transaction boundaries, LISTEN/NOTIFY. Pinned connections defeat the multiplexing benefit. Audit your application for these patterns before expecting RDS Proxy to solve connection exhaustion.
When to Choose RDS Over Aurora
RDS is appropriate when:
- The workload is modest (under ~10K TPS per instance)
- The team is familiar with vanilla PostgreSQL or MySQL operations
- Cost matters more than performance characteristics
- You need a database engine that Aurora does not support (SQL Server, MariaDB)
- You want the simplest possible managed database without Aurora-specific features
A db.t3.medium Multi-AZ RDS PostgreSQL deployment costs roughly $104/month. Aurora starts higher. For a side project, internal tool, or application with predictable, modest load, the cost difference is real.
Aurora: Relational at Higher Scale
Aurora is not a managed version of PostgreSQL or MySQL. It is a ground-up reimplementation of the storage and replication layers with Aurora-specific architecture, exposing a PostgreSQL or MySQL wire-compatible interface. The distinction matters for understanding its performance characteristics.
The Storage Architecture
Aurora stores data in a distributed shared volume across 3 Availability Zones with 6 copies total. The primary instance and all read replicas attach to this same shared storage—they do not each maintain their own copy. This has several consequences:
Replication to read replicas is near-instant. Because replicas read from the same storage volume as the primary, replication lag is typically single-digit milliseconds rather than the seconds possible with traditional async replication.
Storage auto-grows and auto-shrinks. The shared volume expands in 10 GB increments as needed, up to 128 TB, without any manual intervention. When data is deleted, Aurora reclaims space automatically. Compare this to RDS, where you must either pre-provision storage or enable storage autoscaling (which only grows, never shrinks).
Failover is faster. Because there is no standby to “catch up” to the primary, Aurora failover to a read replica takes under 30 seconds. There is no lag to close. One of the read replicas is promoted to writer, the cluster endpoint DNS record updates, and the former primary rejoins as a replica.
The 6-copy model provides resilience. Aurora writes succeed when 4 of 6 copies acknowledge (write quorum). Reads succeed with 3 of 6 (read quorum). Aurora can survive the loss of one full AZ without losing write availability.
Read Replica Scaling
Aurora supports up to 15 read replicas across all sizes. All share the writer’s storage. Adding a replica takes minutes, not the hours of copying a full dataset needed for traditional async replication.
Each replica is independently sized. You can run a db.r6i.xlarge writer with db.r6i.large read replicas for cost efficiency. Route reporting and analytics queries to lower-priority replicas without affecting the writer.
Aurora Serverless v2
Aurora Serverless v2 maintains the shared distributed storage model but replaces fixed instance sizing with automatic scaling measured in Aurora Capacity Units (ACUs). One ACU is approximately 2 GB of RAM with proportional CPU and network bandwidth.
You set a minimum and maximum ACU range. Aurora scales within that range based on demand, in 0.5-ACU increments, in seconds. The minimum can be as low as 0.5 ACU (~$0.12/hour when running). Setting the minimum to 0 pauses compute entirely when idle—you pay only for storage. The paused→active transition takes a few seconds and involves a brief cold period where the buffer cache is empty, which manifests as higher latency on the first queries after idle.
Serverless v2 scaling behavior:
min: 2 ACU, max: 16 ACU
Traffic ACU Cost/hr Notes
--- 2 $0.48 Weekend baseline
Low 2 $0.48 Buffer cache warm from min ACU
Medium 6 $1.44 Scales up within seconds
Spike 14 $3.36 Peak event handling
Recovery 4 $0.96 Scales down, buffer cache still warm
Billing is per second, with ACU consumption measured at one-second granularity. Serverless v2 is cheaper than provisioned Aurora when the workload is idle or low more than roughly 40–50% of the time. For sustained high-utilization workloads (60%+ of the time at near-peak load), provisioned Aurora with Reserved Instances wins on cost.
The buffer cache behavior on scale-down is frequently misunderstood. Serverless v2 does not fully cold-start like v1 did—the instance stays running, the buffer cache is preserved during scale operations. But at the minimum ACU setting, the buffer cache is smaller than it would be at peak capacity. If your workload requires a large working set in memory and you set minimum too low, you will see cache misses after idle periods. Set your minimum ACU to at least half your expected sustained working set requirement.
Aurora PostgreSQL vs MySQL
Use Aurora PostgreSQL when:
- You need JSONB, window functions, CTEs, lateral joins, or other PostgreSQL-specific features
- You are already using PostgreSQL and want a higher-performance managed version
- Your team’s PostgreSQL expertise is an asset
Use Aurora MySQL when:
- You are already using MySQL and want minimal migration friction
- Your application relies on MySQL-specific behavior (different JSON handling, specific stored procedure syntax)
The performance characteristics at the storage level are similar between Aurora PostgreSQL and MySQL. Choose based on your application’s query requirements and your team’s expertise, not on performance speculation.
DynamoDB: Key-Value and Document Access at Any Scale
DynamoDB is not a better relational database. It is a different kind of database, designed for a different set of access patterns: high-throughput key-value lookups, time-series events, user activity feeds, gaming leaderboards, IoT sensor data. Its design reflects a deliberate trade-off—partition the data to scale writes horizontally, but accept that multi-entity queries require design-time planning rather than SQL flexibility.
Partitioning and the Hotspot Problem
DynamoDB partitions data using consistent hashing on the partition key. Each partition handles a maximum of 3,000 Read Capacity Units and 1,000 Write Capacity Units per second. If your access pattern concentrates reads or writes on a small number of partition keys, those partitions become hot and throttle requests even if the overall table capacity is sufficient.
Classic hotspot scenarios:
dateas the partition key on a time-series table: all current writes hit today’s partitionuser_idfor a viral user: millions of reads against one partition- Monotonically increasing IDs: all writes hit the latest partition range
Solutions:
Write sharding: Append a random suffix to high-traffic partition keys. STATUS#pending becomes STATUS#pending#0 through STATUS#pending#9. Reads must query all 10 shards and merge results—add this to your read path explicitly.
Partition key diversity: Design access patterns so partition keys are naturally distributed. A user ID hash distributes evenly; a status field with only three values does not.
Event aggregation: For write-heavy time-series data, write events to DynamoDB Streams (24-hour retention), consume them in Lambda, and aggregate into summaries. The raw events fan out across partition keys by time bucket; the summaries fan out by entity.
Table-level auto-scaling: On-demand mode manages throughput automatically and uses DynamoDB’s split-for-heat feature to detect and automatically split hot partitions. This does not eliminate throttling on extreme hotspots but handles most gradual growth patterns.
Item and Request Structure
DynamoDB items have a 400 KB maximum size. This covers the item data plus attribute names—verbose attribute names consume item budget. The 400 KB limit is also the per-request limit for transactions.
|
|
GSIs vs LSIs
| Factor | GSI | LSI |
|---|---|---|
| Creation time | Any time | Only at table creation |
| Throughput | Separate from table | Shared with table |
| Consistency | Eventually consistent | Strongly consistent available |
| Size limit | Unlimited | 10 GB per partition key |
| Partition key | Any attribute | Must match table partition key |
Prefer GSIs for most use cases. LSIs are a legacy feature with significant constraints (especially the 10 GB per-partition limit) and can only be created at table creation time. Create GSIs liberally to support your access patterns. GSIs replicate data asynchronously, so they add write cost proportional to GSI count and key projection size.
Single-Table Design
Single-table design is the practice of storing multiple entity types in one DynamoDB table using generic key names (pk, sk) and overloading their values with entity type prefixes:
pk sk attributes
USER#123 PROFILE {name, email, created_at}
USER#123 ORDER#456 {total, status}
USER#123 ORDER#789 {total, status}
ORDER#456 METADATA {user_id, total, created_at}
ORDER#456 LINEITEM#001 {product, quantity, price}
PRODUCT#ABC INVENTORY {stock, warehouse}
This enables fetching “all items related to a user” or “all items in an order” with a single Query on the partition key—no joins, no multiple round trips.
Single-table design is the right choice when:
- You have well-understood, stable access patterns
- You need atomic transactions across related entities (transact_write within the same table)
- Operational simplicity is valued (one table to monitor, backup, and scale)
Single-table design is the wrong choice when:
- Access patterns are still evolving or unknown
- Your team is not yet fluent in NoSQL modeling—debugging a single-table schema requires deep familiarity with the access pattern map
- You need complex analytics across entity types (DynamoDB is not an analytics database; export to S3 + Athena or Redshift for this)
DynamoDB Accelerator (DAX)
DAX is an in-memory read-through and write-through cache for DynamoDB. Items read through DAX are cached with a default 5-minute TTL. Cache hits return in microseconds; cache misses pass through to DynamoDB and return in single-digit milliseconds.
DAX is appropriate when:
- You have read-heavy workloads with repetitive access to the same items (leaderboards, product catalog, configuration data)
- You need microsecond read latency for specific items
- You are already at DynamoDB pricing and want to reduce read capacity unit costs
DAX is not appropriate when:
- Your workload is write-heavy (DAX only caches reads)
- You need strongly consistent reads (DAX serves eventually consistent reads from cache)
- Your access pattern has low item reuse (cache hit rate will be poor)
DAX clusters start at roughly $170/month for a 3-node cluster (minimum for production). Evaluate whether the capacity unit savings and latency improvement justify this before deploying.
ElastiCache: In-Memory Caching and Data Structures
ElastiCache provides managed Redis/Valkey and Memcached. The choice between them is almost always Redis/Valkey.
Redis vs Memcached
Memcached is a pure cache—simple key-value store, multi-threaded, no persistence, no replication. If you genuinely need nothing beyond a shared cache with maximum throughput and you are comfortable with data loss on node failure, Memcached is slightly simpler. For every other use case, Redis/Valkey is appropriate.
Redis provides:
- Rich data structures: strings, hashes, lists, sets, sorted sets, HyperLogLog, streams, geospatial indexes
- Optional persistence (AOF for durability, RDB for snapshots)
- Replication and cluster mode for horizontal scaling
- Pub/sub messaging
- Lua scripting for atomic multi-command operations
- WAIT command for synchronous replication
Valkey
AWS introduced Valkey support in ElastiCache in 2024. Valkey is a Linux Foundation-backed fork of Redis created after Redis changed its license from BSD to a more restrictive dual-license. Valkey remains BSD-licensed and has backing from AWS, Google, Oracle, Ericsson, and over 40 other organizations.
Valkey is API-compatible with Redis 7.2—all existing Redis client code works without changes. ElastiCache Valkey nodes are 20% cheaper than equivalent Redis nodes for node-based pricing, and 33% cheaper for ElastiCache Serverless. For new deployments, choose Valkey unless you have a specific reason to stay on Redis.
Cluster Mode vs Non-Cluster
Non-cluster mode (single shard): One primary, up to 5 read replicas. All data lives on one primary. Vertical scaling (larger instance) is the scaling strategy. Simple to operate, but bounded by the instance size.
Cluster mode: Data partitioned across multiple shards (default 3, up to 500). Each shard has a primary and optional replicas. Horizontal scaling—add shards as data or throughput grows. Multi-key operations (MGET, MSET, Lua scripts, transactions) require all keys to be in the same slot, which requires hashing those keys into the same slot using hash tags: {user:123}:session, {user:123}:cart.
Use cluster mode when:
- Your dataset exceeds the memory of the largest available instance (~500 GB on r7g.16xlarge)
- Your write throughput exceeds what a single primary can handle
- You want to horizontally distribute read load beyond 5 replicas
Use non-cluster mode when:
- Your dataset fits comfortably in memory on a single large instance
- You heavily use multi-key operations and cannot easily add hash tags
- Operational simplicity is prioritized
ElastiCache Serverless
Launched in late 2023, ElastiCache Serverless provides Redis/Valkey caching without node sizing decisions. It automatically provisions capacity across 3 Availability Zones (or 2 in us-west-1), scales based on demand, and guarantees sub-millisecond read and write latency.
Pricing is based on data stored (per GB-hour) plus data processed (per ECU-hour, where 1 ECU is 1,000 requests/second). At low data volumes and request rates, Serverless is often cheaper than the minimum node-based cluster. At high sustained throughput, node-based pricing wins.
Serverless is a good starting point when cache requirements are uncertain. Switch to node-based when you have consistent traffic patterns and can benefit from Reserved Node pricing.
Caching Patterns
Cache-aside (lazy loading): The application checks the cache, misses to the database, writes to the cache, and returns the result. The cache only contains data that has been explicitly requested—no unused data.
|
|
Write-through: When the application writes to the database, it also writes to the cache. Cache is always up-to-date but may contain data that is never read.
Write-behind (write-back): Writes go to cache immediately and are batched to the database asynchronously. Maximum write performance, but risk of data loss if cache fails before flush.
Session store: Redis is the standard choice for distributed session storage. Sessions are stored as hashes with a per-session TTL. Horizontal application scaling is possible because any application instance can read any session.
|
|
Rate limiting: Sorted sets or increment-based counters implement sliding window rate limiting without additional infrastructure.
|
|
Choosing Between Them
The decision is not primarily about performance—all four products perform adequately for most web applications. The decision is about query flexibility, consistency requirements, operational complexity, and cost profile.
Relational?
/ \
Yes No
/ \
Need > 10K TPS or DynamoDB
15 read replicas? (if access patterns fit)
/ \
No Yes
/ \
RDS Aurora
(Provisioned) (Provisioned or
Serverless v2)
More specifically:
RDS when you have a conventional relational workload, modest scale, and want the simplest operational path. PostgreSQL or MySQL expertise transfers directly. Lower baseline cost than Aurora.
Aurora Provisioned when you have relational workloads that need high read throughput (multiple read replicas), minimal replication lag, or faster failover than RDS delivers. Worth the cost premium at scale.
Aurora Serverless v2 when your relational workload has high variance—burst traffic patterns, development/staging environments, or workloads where the database is idle a significant fraction of the time.
DynamoDB when your access patterns are key-value or document retrieval with a stable, pre-defined query set. Do not use DynamoDB if your access patterns require ad-hoc querying across multiple dimensions—you will spend more time working around DynamoDB’s constraints than you will save on the operational simplicity. The one consistent mistake with DynamoDB is using it for a relational workload because it “scales better.”
ElastiCache alongside any primary database. Cache the hot read path (user sessions, frequently accessed lookups, expensive computed results), use Redis data structures for real-time features (leaderboards, rate limiting, pub/sub), and accept that the cache is a performance optimization, not a source of truth.
Rough Cost Comparison
For a mid-scale web application (5–10 million operations per month, read-heavy):
| Service | Configuration | Monthly Est. | Appropriate when… |
|---|---|---|---|
| RDS PostgreSQL Multi-AZ | db.t3.medium, 100 GB | ~$104 | Modest traffic, cost priority |
| RDS PostgreSQL + Proxy | db.t3.medium + Proxy | ~$150 | Lambda or heavy autoscaling |
| Aurora Provisioned | db.r6g.large + 1 replica | ~$420 | High throughput, low lag |
| Aurora Serverless v2 | 2–8 ACU, 100 GB | ~$150–350 | Variable load, 40%+ idle |
| DynamoDB On-Demand | 5M reads, 2M writes | ~$8 | NoSQL access patterns |
| DynamoDB + DAX | Cluster + on-demand | ~$180 | Cache-worthy NoSQL |
| ElastiCache Valkey | cache.r7g.large | ~$120 | Hot read cache |
| ElastiCache Serverless | 10 GB, moderate TPS | ~$40–80 | Unknown/variable cache load |
DynamoDB’s extreme cost advantage in the “on-demand” row requires that your data model actually fits DynamoDB’s access pattern requirements. If it does not, you will add GSIs, table copies, and Lambda-powered denormalization until the costs are comparable to Aurora.
Aurora Serverless v2 is frequently competitive with provisioned RDS once you factor in the managed scaling, zero-downtime scaling events, and faster failover—the comparison is not just on instance pricing.
The baseline recommendation for a new application: start with Aurora Serverless v2 for the primary database (minimum 0.5 ACU, maximum appropriate to your projected peak) and ElastiCache Serverless for caching. Both scale automatically; neither requires capacity planning before your traffic patterns are known. Once patterns stabilize, revisit whether provisioned instances with Reserved pricing would reduce costs.
Comments