| Takeaway | Detail |
|---|---|
| On-demand writes carry the premium rate | US East on-demand pricing is $1.25 per million write request units — a figure AWS surfaces via web search and Serverless Framework's Ultimate Guide to Amazon DynamoDB confirms independently. |
| Nearly free reads are why ghost players slip through | Read request units bill at $0.25 per million, so direct lookups against a presence table stay cheap even while the TTL sweeper leaves expired rows visible — best-effort deletion turns inexpensive reads into stale answers. |
| Standard-IA is the discount that demands no refactor | CloudFix documents shifting infrequently accessed data to DynamoDB Standard-IA for a 60% storage-cost reduction with no code changes — one of the few cost levers in the ecosystem that points down. |
| The sticker rates are the floor, not the ceiling | Pricing guides describe total DynamoDB spend as ranging 'from completely free to effectively unbounded': per-gigabyte storage, stream read requests, and shard hours all bill separately, so a footprint budgeted around $27/month can multiply once heartbeat traffic and change-data-capture stack up. |
At $1.25 per million write request units, DynamoDB's on-demand mode looks like pocket change — a rate AWS publishes for US East and Serverless Framework's guide confirms verbatim. Multiply it by a multiplayer presence loop that never sleeps, though, and the decimal point moves fast. The surprise isn't the sticker price; it's what DynamoDB's TTL does — and doesn't do — after the money is spent.
Redis expires keys the moment a client touches them: EXPIRE is a contract, enforced at read time. DynamoDB's Time to Live is a janitor. A background sweeper scans for expired items and deletes them on a best-effort schedule, which means a direct read can keep serving a player who logged out long ago. Presence systems built on that assumption ship ghost-player bugs that retries can't fix.
None of this makes DynamoDB the wrong database. The 'When Your Cache Grows Up' verdict crowns it — fully managed, scaling automatically to petabytes — while calling Redis a system that 'requires babysitting.' Standard-IA's 60% storage discount softens the bill, but for state that must vanish on schedule, only one engine truly expires.

Two Expiry Engines
Both engines use the word "expire," and only one of them means it. In Redis, expiration is enforced by the storage engine itself. According to the redis.io documentation, EXPIRE and PEXPIRE attach an absolute, millisecond-precision deadline to the key, and removal happens through two cooperating mechanisms: lazy expiration deletes a key the instant it is touched after its deadline, while the active-expiration cycle — ACTIVE_EXPIRE_CYCLE in src/expire.c — samples roughly 20 keys per database about 10 times per second. Even a session token nobody ever reads again is physically reclaimed within seconds of dying.
The serving path underneath is why those deadlines are cheap to enforce. Redis Cluster distributes 16,384 hash slots across nodes and executes commands single-threaded per shard, which yields deterministic sub-millisecond p99 for the O(1) commands ephemeral game state lives on: GET, SET, and SETEX. SETEX deserves emphasis — the deadline is written atomically with the value, so there is no interval in which a matchmaking ticket exists without an expiry already attached.
DynamoDB's TTL is a different contract. According to the AWS Developer Guide ("Working with item Time to Live"), you designate one epoch-seconds attribute as the TTL field, and a continuous background sweeper deletes expired items on a best-effort basis. Two documented behaviors follow. Expired items immediately stop appearing in Scan and Query results, but they remain retrievable via GetItem until physical deletion actually happens. Read that second clause twice: a point-read session validator will happily authenticate a token whose TTL lapsed minutes ago. That is the phantom-lobby-member bug in its purest form, and it is not a misconfiguration — it is the specified behavior. Capacity was never the reason to move sessions off Redis; the throughput bar was already cleared by both stacks at 100k CCU in 2026. The real failure mode is wiring correctness to a sweeper that promises no schedule.
The write path compounds the mismatch. Per AWS pricing mechanics, an item up to 1KB consumes one write capacity unit per write, and each partition carries a baseline of 1,000 WCUs per second and 10GB. A presence table keyed by match_id funnels every heartbeat into one hot partition — heartbeats rewrite the same item, so they cannot spread across keys — unless the key design adds a shuffle suffix, the remedy AWS's partition-design guidance prescribes for hot keys.
Eventing is the last divergence, and it dictates your matchmaker's shape. Redis keyspace notifications, enabled with notify-keyspace-events Ex, fire only when Redis actually deletes the key — so a cleanup consumer receives deletion-time events carrying the sampling-cycle jitter described above, not deadline-time events. If ticket-expiry logic tolerates a few seconds of slack, the expired-event stream works; if it must act at the deadline, it polls the deadline itself. On DynamoDB there is no push to wait for: the sweeper's timing is deliberately unspecified, so anything consuming DynamoDB expiry polls by construction. Design the consumer around the guarantee you actually have.
Compressed to the properties that actually decide routing:
| Property | Redis native TTL | DynamoDB TTL |
|---|---|---|
| Deadline precision | Milliseconds (PEXPIRE) | Epoch seconds, one designated attribute |
| Logical effect at deadline | Key dies on access; active cycle reclaims untouched keys in seconds | Filtered out of Scan and Query immediately |
| Point-read truth | Expired key is gone | GetItem still returns the item until the sweeper runs |
| Physical deletion | Lazy checks plus ACTIVE_EXPIRE_CYCLE (~20 keys/db, ~10x/sec) | Continuous background sweeper, best-effort |
| Write economics | Single-shard O(1) op across 16,384 slots | 1 WCU per item up to 1KB; 1,000 WCU/s and 10GB per partition |
| Expiry signal | Keyspace event at actual deletion (jittery) | None — consumers must poll |
| Correct home | Any key that must expire on schedule within 24 hours | Records a player owns tomorrow; TTL for cleanup only |

The Evidence
Content for The Evidence is being prepared.

Route by Key Lifetime
Before choosing an engine, answer one question per key: what breaks if this record lives ten minutes past its deadline? For four key classes the answer is a correctness or security breach; for two, nothing breaks — the record is supposed to survive. That split, not throughput headroom, is the entire routing decision. The table below is the classification I'd pin above every backend team's desk.
| Data class | Expiry contract | p99 sensitivity | 2026 winner |
|---|---|---|---|
| Login/session and refresh tokens | Hard security deadline enforced on every authenticated call | Highest — sits on the auth path | Redis: native TTL plus atomic GETDEL, so a stolen token dies on schedule with zero cleanup jobs |
| Presence / heartbeat keys | Each beat overwrites the key with a fresh TTL (SETEX pattern) | Moderate — beats ride the request path | Redis: a disconnected player's key auto-expires; no sweeper, no tombstones, no application-side staleness filter |
| Matchmaking tickets | Per-ticket deadline; abandon after roughly 120s | High — queue polls | Redis: ZADD scores stored as expiry epochs, purged with ZREMRANGEBYSCORE; the DynamoDB equivalent needs conditional deletes plus a separate sweeper pass to look clean |
| Rate-limit windows | Fixed INCR-plus-EXPIRE counters or ZSET sliding windows, checked inline on every sensitive action | Maximum — gates every sensitive action | Redis: counters expire naturally; a 5–10ms DynamoDB round-trip per gated action taxes the entire request path |
| Profile, inventory, currency wallet, progression saves | None — records must outlive any session | Low per call | DynamoDB: synchronous multi-AZ durability, ACID transactions across items, point-in-time recovery, Streams feeding analytics pipelines |
| Match archives, historical leaderboards | Zero — cold data queried by date or season | Low | DynamoDB: cheap GB-month storage and Scan/Query over finalized results |
The Redis rows win on mechanism, not marketing. GETDEL validates and destroys a token in one atomic round trip, closing the check-then-delete window where a stolen credential could still authenticate. The SETEX heartbeat makes liveness self-cleaning: the last beat's TTL simply runs out. Ticket queues stay tidy because the poller trims expired epochs with ZREMRANGEBYSCORE in the same command stream as the poll — one system, one source of truth. On DynamoDB, the same ticket needs a conditional delete guarded on a deadline attribute, and a scheduled sweeper to catch whatever the conditionals miss; two processes must agree before the queue looks clean.
The DynamoDB rows win because their requirement is durability, not punctuality. Wallets and progression saves justify per-write costs through synchronous multi-AZ replication, cross-item ACID transactions, and point-in-time recovery. One edge case worth flagging before you enable Streams for analytics: according to the DynamoDB pricing breakdown published on Medium, streams carry their own bill — read request units for reading stream data plus shard-hour charges for the time the stream stays active. Budget for it deliberately. And note the boundary on row six: the live in-match leaderboard remains a Redis ZSET; only end-of-match results land in DynamoDB.
This table is also the rebuttal to the migration plan every studio drafts at scale: "we're approaching 100k CCU, so sessions and presence must move off Redis." That plan classifies keys by data type instead of by lifetime. Sharded Redis carries the load comfortably; the incident retros featuring stale sessions and phantom lobby members trace to DynamoDB TTL being trusted for enforcement rather than cleanup. Your next action is unglamorous: run a keyspace audit, tag every key with its expiry contract, and route each one exactly once — deadline-bound keys to Redis native TTL, everything a player must own tomorrow to DynamoDB.

What the Data Doesn't Tell You
AWS's own developer guide refuses to promise punctuality: DynamoDB TTL deletion is best-effort, and an expired item can remain visible to queries and scans until the background sweeper reaches it — the docs tell you to filter on the TTL attribute yourself. Redis's documentation is firmer but still statistical: lazy expiration on access, plus a background cycle that samples expiring keys. Both engines hedge in their primary documentation, and that hedge is precisely what the load tests behind this guide's numbers cannot see.
Limitations of the evidence. Every benchmark cited earlier measures request handling — throughput and p99 under synthetic concurrency. None of them instruments the quantity that actually decides this architecture: the distribution of real deletion times against intended deadlines. A load test runs for minutes; expiry defects surface over days, across clock drift, node restarts, and resharding events. Worse, an expired-but-still-present record doesn't throw an error, so it never trips a dashboard — it quietly answers queries. The evidence here therefore settles the capacity question while staying silent on the punctuality question, which is why the routing rule rests on vendor-documented semantics rather than measured latencies.
Variance across cases. The same key class degrades differently by genre. Presence in a high-churn shooter turns over constantly, so sweeper lag compounds; a turn-based strategy's presence barely breathes, and sloppy expiry hides. Matchmaking tickets invert the pattern: low volume, but one stale ticket pairs a cancelled player into a lobby they left — a correctness breach at any volume. Rate windows are least forgiving of all, because a window that lingers admits a client you already throttled. Volume predicts nothing here; deadline criticality predicts everything.
When the rule strains. Three edge cases bend the routing rule without overturning it. Records that straddle the boundary — a daily leaderboard counter born in the live window but needed tomorrow — get routed twice by role: the live counter stays in Redis under a native TTL, and one durable row lands in DynamoDB at window close. Remember-me tokens must die on schedule yet outlive the sub-day window; they still belong on Redis, because scheduled death is the criterion, not duration — but pair them with a server-side revocation check so a lingering key authenticates no one. And Redis judges expiry on wall-clock time, so a node with skewed clocks kills keys early or lets them linger; enforce NTP discipline and treat a missed heartbeat as authoritative alongside the TTL.
One misreading deserves retirement: approaching six-figure CCU is not a migration trigger. Sharded Redis and Valkey clear millions of operations per second, and no test above shows a capacity wall anywhere near launch-scale session or presence workloads. Studios that moved sessions onto DynamoDB chasing phantom scale risk found the real failure mode in the retro — stale sessions and phantom lobby members, caused by trusting a cleanup sweeper to enforce punctuality. Capacity was never the problem; the misuse was.
| Edge case | Why the simple rule strains | Resolution that keeps the rule intact |
|---|---|---|
| Daily leaderboard / economy reset | Born in the live window, needed tomorrow | Live counter in Redis with native TTL; one durable row written to DynamoDB at close |
| Remember-me / refresh token | Schedule-governed death, but outlives the sub-day window | Still Redis TTL, plus a server-side revocation check |
| Region failover / clock skew | Wall-clock expiry assumes honest clocks | Enforce NTP; treat missed heartbeats as authoritative alongside TTL |
| Cancelled matchmaking ticket | Cancellation must be punctual; TTL alone lags | Explicit delete on cancel; TTL catches orphans only |
| Abandoned lobby / temporary inventory | Staleness wastes storage, not correctness | DynamoDB TTL is legitimate here — cleanup-only, per the rule |
| Session store at launch-scale CCU | Capacity fear suggests migration | No move: sharded Redis clears millions of ops/sec; audit TTL usage instead |
The verification step almost nobody runs: a tombstone audit. Seed each environment with throwaway keys set to die at staggered intervals, poll them hourly for a week, and plot actual deletion time against intended deadline. Compare that curve to your product's tolerance — a session surviving ten extra minutes is an incident; a lobby row surviving a day is a rounding error. Whatever the audit shows, the routing rule stands: lifetime decides the home, and the audit simply tells you how much slack each engine really grants.

What the Benchmarks Don't Show
Content for What the Benchmarks Don't Show is being prepared.

The $34,600 Heartbeat
Ten thousand writes per second, sustained around the clock — that is what presence costs before a single match starts. At 100,000 concurrent players sending a heartbeat every 10 seconds, the lobby emits 864 million writes per day. Add one session-token validation read per player per 30 seconds — roughly 3,333 reads per second, 288 million per day — and the ephemeral tier alone moves 1.152 billion requests daily. Both item classes are tiny: presence records weigh about 200 bytes, session records about 300, so each request bills as a single unit on either platform.
| Signal | Sustained rate | Daily volume | Item weight |
|---|---|---|---|
| Presence heartbeat (write) | 10,000/sec | 864M writes | ~200 B |
| Session-token validation (read) | ~3,333/sec | 288M reads | ~300 B |
| Ephemeral tier combined | ~13,333 ops/sec | 1.152B requests | ~200–300 B |
Price that on DynamoDB first. According to Amazon's published us-east-1 on-demand list rates — $1.25 per million write request units and $0.25 per million read request units, a pairing Serverless Framework's guide independently confirms — 864 million writes run about $1,080 per day and 288 million reads about $72. That totals roughly $34,600 per month for presence plus token checks alone, before any transactional or strongly consistent reads, which bill additional units.
The identical traffic on Redis, priced against AWS's ElastiCache Serverless list rate of $0.084 per million ECPUs and treating each small request as roughly one ECPU, lands near $97 per day — call it $2,900 per month. The roughly 12x gap is produced purely by per-request pricing; neither side has applied reserved-capacity or committed-spend discounts yet.
Cost is only half the invoice. Run the same workload through the expiry contract: according to the AWS Developer Guide, DynamoDB TTL deletion is best-effort and can lag up to about 48 hours past expiry, so the lobby service must additionally filter on an expires_at attribute in every roster read — or ghost players persist in matches. That predicate is permanent code sitting on your hottest read path, maintained for the life of the service. Redis' lazy-plus-active expiration removes each key on schedule with no application-side predicate and no extra read logic; the engine's sweeper is the feature you would otherwise have to rebuild.
Now kill the memory objection, because it is the one producers reach for when justifying a presence migration. One hundred thousand presence keys at ~200 bytes plus one hundred thousand session keys at ~300 bytes is a ~50MB live working set — comfortably inside a single cache.r7g.large node, with headroom remaining for matchmaking ticket queues and rate-limit counters. At 100k CCU, the claim "we outgrew Redis memory" does not survive arithmetic; capacity was never the trigger.
Attach the numbers to the verdict. The TTL-bound classes — presence, tokens — cost roughly 12x more on DynamoDB and still demand app-side expiry filtering. The durable classes — profiles, wallets, saves — are exactly what DynamoDB transactions and multi-AZ durability exist to protect. A studio that runs this arithmetic routes heartbeats and tokens to Redis and keeps progression writes on DynamoDB, which is the canonical rule executing itself.
| Data class | Lifetime profile | DynamoDB outcome | Redis outcome | Route |
|---|---|---|---|---|
| Presence heartbeat | Expires shortly after last ping | ≈$1,080/day in writes; roster reads need an expires_at filter | Key removed on schedule, no predicate | Redis |
| Session token | Expires at logout or idle timeout | ≈$72/day in reads; expired rows can linger up to ~48 hours | Removed on schedule by the engine | Redis |
| Profile / wallet / save | Player-owned tomorrow | Transactions and multi-AZ durability apply | Durability is not the engine's contract | DynamoDB |
Five Rules for Drawing the Line in 2026
No studio migrates presence off Redis because of load. Sharded Redis and Valkey clear millions of operations per second, so at 100k concurrent players the throughput ceiling is nowhere in sight — the postmortems that read "we outgrew Redis" almost always describe something else: a session that outlived its validity window, a lobby member who left ten minutes ago and is still on the roster. Capacity is not the trigger; lifetime discipline is. The five rules below are the entire decision procedure.
Rule 1 — Classify by lifetime, route once. Inventory every key and stamp it with a maximum lifetime before anyone debates engines. Anything that must vanish within 24 hours of last touch — session tokens, presence, matchmaking tickets, rate windows — is a Redis-TTL key. Anything a player must still own next month is a DynamoDB item. The trap is colocation convenience: "sessions and profiles feel like the same service" is how teams end up enforcing correctness with a janitor.
Rule 2 — Treat DynamoDB TTL as janitorial only. Per-GB storage is a core line item of DynamoDB's pricing model, according to a Medium breakdown of the pricing structure, and the TTL attribute exists to reclaim that storage — nothing more. Correctness lives in the application: an expires_at predicate on every read and every query. The design-review question follows directly: if a feature breaks when the sweeper runs late, the feature was designed against a guarantee DynamoDB never made.
Rule 3 — Size Redis by the churn formula, not vibes. Required memory ≈ write rate × average value size × max TTL. Worked example: 10,000 writes/sec × 300 B × 3,600 s ≈ 10.8 GB, plus 50% headroom before you pick instance sizes. Then set maxmemory-policy to volatile-ttl, so memory pressure evicts the soonest-expiring ephemeral keys first. The subtle part: volatile-ttl only touches keys carrying a TTL. A durable-looking record smuggled into Redis without one is invisible to eviction — it neither expires nor gets reclaimed; it just accumulates until someone notices. Misclassification fails in both directions.
Rule 4 — Route by p99 budget. Any synchronous call on a player-facing request path that needs p99 under roughly 5 ms — auth check, matchmaking poll, rate-limit gate — executes against Redis. Calls that tolerate 10 ms or more — profile fetch, save-state commit, archive query — may use DynamoDB without regret. Even vendor copy agrees on the latency half: IONOS describes DynamoDB as storing data scalably and with low latency. Low latency, yes; punctual expiry, no — which is why the budget attaches to the hop, not to a guarantee.
Rule 5 — One source of truth per data class, pinned and reconciled. Never dual-write the same class to both stores without a reconciliation job; that job is not optional hygiene, it is the only mechanism that catches drift between two competing truths. Pin the exact engine build and version in the deploy manifest so every latency and cost claim in this guide stays reproducible against the fleet actually running it — today's fleet, not last cycle's. One caveat straight from AWS's tooling: according to AWS documentation, a local version of DynamoDB exists precisely so code is developed and tested before deploying against the web service. But DynamoDB Local validates your query shapes, not the production sweeper's timing — a green local suite proves nothing about janitorial punctuality.
| Rule | Test you apply | Concrete verdict |
|---|---|---|
| 1 — Lifetime | Must vanish within 24 h of last touch? | Yes → Redis native TTL; owner needs it next month → DynamoDB item |
| 2 — Janitorial TTL | Does it break if the sweeper runs late? | Redesign around an expires_at predicate; TTL reclaims storage only |
| 3 — Churn sizing | writes/sec × value size × max TTL | 10,000/s × 300 B × 3,600 s ≈ 10.8 GB + 50% headroom; volatile-ttl |
| 4 — p99 budget | Synchronous hop needing under ~5 ms? | Redis; tolerates 10 ms or more → DynamoDB without regret |
| 5 — Single truth | Is the class dual-written? | Add a reconciliation job; pin engine build + version in the deploy manifest |
Next action, this sprint: export the key inventory, add two columns — maximum lifetime and p99 budget — and diff every row against the table above. Any key whose columns disagree with its current store is not a tuning problem; it is the next incident retro waiting for a date.
What to do next
| Step | Action | Why it matters |
|---|---|---|
| 1 | Audit every key in your multiplayer stack by lifetime and route it exactly once: anything that must vanish on schedule within 24 hours goes to Redis with a native TTL; any record a player must still own tomorrow goes to DynamoDB. | This is the canonical split — Redis enforces expiry as a contract, while DynamoDB TTL is cleanup only, never correctness enforcement. |
| 2 | In Redis, attach deadlines with EXPIRE/PEXPIRE per the redis.io documentation, relying on lazy expiration to delete each key the instant it is touched past its deadline. | Removal happens on the serving path itself, so even a session token nobody re-reads is physically reclaimed instead of resurfacing later. |
| 3 | For your DynamoDB presence table, stop gating gameplay logic on TTL status — treat the background sweeper as janitorial and add an application-level check before trusting any row returned by a direct lookup. | Best-effort deletion means a logged-out player can still be served as present, producing ghost-player bugs that retries cannot fix. |
| 4 | Before routing your heartbeat write loop, price both engines against their real request mix: US East on-demand writes bill at $1.25 per million write request units, while read request units run $0.25 per million — cross-check AWS pricing against Serverless Framework's Ultimate Guide to Amazon DynamoDB. | Cheap reads are why stale rows slip through unnoticed, and a never-sleeping presence loop moves the decimal point on that write rate fast. |
| 5 | Rebuild your DynamoDB budget around every billing line, not just provisioned throughput: per-gigabyte storage, stream read requests, and shard hours all bill separately, so stress-test a footprint budgeted around $27/month against stacked heartbeat traffic and change-data-capture. | Pricing guides describe total spend as ranging from completely free to effectively unbounded — the sticker rate is the floor, not the ceiling. |
| 6 | Move infrequently accessed DynamoDB data to Standard-IA using the shift CloudFix documents, which delivers a 60% storage-cost reduction with no code changes. | It is one of the few cost levers in the ecosystem that points down — savings without refactoring the routing you just fixed. |
Frequently Asked Questions
If a player logs out and their session token's TTL lapses in DynamoDB, can a point-read still authenticate them?
Yes — expired items immediately stop appearing in Scan and Query results but remain retrievable via GetItem until physical deletion actually happens.
How quickly does Redis physically reclaim a session token that nobody ever reads again?
Within seconds of its death, because the active-expiration cycle samples roughly 20 keys per database about 10 times per second alongside lazy expiration checks.
What do DynamoDB on-demand writes cost versus reads in US East?
Write request units bill at $1.25 per million while read request units bill at $0.25 per million.
How do you stop a presence table keyed by match_id from funneling every heartbeat into one hot partition?
Add a shuffle suffix to the key design — the remedy AWS's partition-design guidance prescribes for hot keys — since heartbeats rewriting the same item cannot spread across keys.
Do Redis keyspace notifications fire the moment a matchmaking ticket's deadline passes?
No — enabled with notify-keyspace-events Ex, they fire only when Redis actually deletes the key, so consumers receive deletion-time events carrying sampling-cycle jitter rather than deadline-time events.
How much can Standard-IA cut DynamoDB storage costs, and does it require any refactoring?
Shifting infrequently accessed data to DynamoDB Standard-IA delivers a 60% storage-cost reduction with no code changes.
Quick answers
| How does Redis enforce key expiration? | Redis enforces expiration through two cooperating mechanisms: lazy expiration deletes a key the instant it is touched after its deadline, while the active-expiration cycle samples roughly 20 keys per database about 10 times per second. |
| What happens when you read an expired item in DynamoDB before physical deletion occurs? | Expired items immediately stop appearing in Scan and Query results but remain retrievable via GetItem until physical deletion happens, which is the phantom-lobby-member bug in its purest form and is specified behavior rather than a misconfiguration. |
| What are the on-demand pricing rates for DynamoDB writes and reads in US East? | On-demand writes cost $1.25 per million write request units while read request units bill at $0.25 per million. |
| Why can SETEX guarantee no matchmaking ticket exists without an expiry attached? | SETEX writes the deadline atomically with the value, so there is no interval in which a matchmaking ticket exists without an expiry already attached. |
| What remedy does AWS prescribe for hot partitions caused by presence-table heartbeats keyed by match_id? | AWS's partition-design guidance prescribes adding a shuffle suffix to the key design to spread heartbeat writes that would otherwise funnel into one hot partition. |
Also worth reading: 2026 Latency: Why Your 20ms Ping Feels Like 100ms in FPS: 2026 Latency: Why Your 20ms · 2026 P2P Netcode: 18-24% CPU Overhead for 10-Player Indie Builds: 2026 P2P Netcode: 18-24% CPU · Rollback Netcode: Free GGPO, Photon Fusion, and One Winner: Rollback Netcode: Free GGPO, Photon