cerf
Duolingo

Duolingo

Duolingo: serving streaks to 100+ million learners on DynamoDB

One row per learner per course, tens of billions of items, and a cache TTL change that erased 2.1 billion API calls a day.

Why a key-value store runs the classroom

Duolingo's hot path is brutally simple and brutally frequent. Every time a learner opens the app, the backend must fetch that one user's state: their streak, their XP, their progress in every course they are enrolled in. It never needs to join that data with anything else on the read path, and it needs the answer fast for over 128 million monthly active users hitting more than 500 backend services (per Duolingo's platform team at InfoQ Dev Summit Munich 2025). That shape, single-user lookups at enormous volume with no relational queries in the request path, is exactly what Amazon DynamoDB is built for, and AWS has long featured Duolingo as a marquee customer, at one point profiling them storing 31 billion items in the service.

The interesting part is how the data is modeled. DynamoDB rewards access-pattern-first design: you decide what questions the application will ask, then shape items so each question is one key-value operation. Duolingo's engineering blog gives a concrete example from Birdbrain, the personalization system that scores every learner in every course. Rather than normalizing scores into many records, 'the scores for each course a learner is enrolled in is stored as a single row in Dynamo to minimize our API latency.' One GetItem returns everything the session needs. Updates are buffered in memory per user with an LRU eviction policy so the service is not hammering the table with a write per answer, and the event streams feeding those models are sharded by userId so all of one learner's activity lands on the same processor.

Compare that to the relational instinct: a users table, a courses table, an enrollments join table, and a query with two joins on every app open. At Duolingo's request volume that design buys you nothing (the app never asks relational questions at read time) and costs you latency and money. The lesson generation engine follows the same philosophy of precomputation: course content is processed offline, serialized to S3, and cached in memory by the serving fleet, so the request path touches only fast, pre-shaped data.

DuolingoAccess-pattern-first modeling: one row per learner vs the relational instinct
Access-pattern-first modeling: one row per learner vs the relational instinctAWS CloudAWS RegionSingle-item model (chosen): one GetItem per app openRelational instinct (rejected): two joins per readBirdbrain scoring + offline lesson precomputationapp open: GetItem by userIdone GetItem → all course scoresread pre-shaped contentjoin 1join 2each answer = one eventall of one learner → one processorflush LRU-buffered scoresserialize offlineload into serving memoryLearner's appiOS / Android / webBirdbrain / APIservicepersonalization sco…DynamoDB item1 row / learner / c…users tablenormalized (rejecte…courses tablenormalized (rejecte…enrollmentsjoin tabletwo joins per app o…Event streamssharded by userIdPer-userprocessorin-memory LRU write…Lessongeneration…offline precomputeSerializedcourse contentS3 objectsIn-memorycourse cacheon the serving fleet
Trace
Duolingo shapes items around the read: Birdbrain stores every course score for a learner as a single row, so one GetItem serves an app open. Answer events are sharded by userId, buffered in memory with LRU eviction, then flushed; course content is precomputed offline into S3 and cached in serving memory. The normalized users/courses/enrollments design is the rejected alternative.

The request path, step by step

Follow a lesson request through the documented stack. The mobile client calls Duolingo's API layer, which is a fleet of microservices that historically ran on Amazon ECS ('for the mass majority, they're on ECS' per Duolingo's platform team at InfoQ) and is now migrating to EKS. The service that owns the learner's data checks a cache first. Duolingo runs Amazon ElastiCache clusters and in-process caches, and treats cache policy as a first-class tuning knob: their cost engineering post describes raising one internal cache's TTL from 1 minute to 1 hour, which cut traffic to the downstream service by more than 60 percent, and a related refactor that eliminated 2.1 billion unnecessary API calls per day.

On a cache miss, the service issues a key-value read to DynamoDB, keyed by user, and gets back the single row holding that learner's per-course state. Because items are shaped around the access pattern, there is no scatter-gather and no fan-out query. How much this caching layer matters showed up when Duolingo built request tracing across their microservices: tracing revealed 'a very common internal method was querying Dynamo multiple times for essentially the same data, rather than caching.' Caching that one method cut Duolingo's overall latency by 10 percent and reduced cost at the same time, because in DynamoDB every avoided read is capacity you do not pay for.

The same table-plus-staging pattern shows up at the extreme end of their traffic. For a Super Bowl ad campaign, Duolingo needed to deliver 4 million push notifications within about 5 seconds of the ad airing. The campaign's target user lists lived in DynamoDB, but the team did not hammer the table at air time: they prefetched the lists into S3 ahead of the game, staged work through FIFO SQS queues (whose 5-minute deduplication window guards against sending anyone a duplicate notification), and ran the workers on a dedicated ECS cluster. 99 percent of notifications arrived within 5.7 seconds. Reliability engineering wraps the whole path: when learners hit a degraded mode during an incident, an internal tool the team calls Freeze Gun tracks those users and makes sure their streak is preserved, because the streak is the product promise the architecture exists to keep.

DuolingoThe lesson request path: cache-aside serving and the Super Bowl push fan-out
The lesson request path: cache-aside serving and the Super Bowl push fan-outAWS CloudAWS RegionServing VPCNotification pipeline (Super Bowl scale)Public subnetPrivate subnet (serving fleet)HTTPS lesson requestroute to owning microservice1. in-process cache2. ElastiCache lookup3. GetItem on miss (single row)emit trace spansdegraded mode → track userprefetch campaign lists pre-gameenqueue campaign sendsload target listsFIFO, 5-min dedupsendpush notificationLearner's app128M+ MAUApplicationLoad BalancerHTTPS ingressAPImicroservicesECS → EKS, 500+ ser…In-processcachefirst rung, fastestElastiCacheTTL raised 1m → 1hDynamoDBsingle row per lear…Request tracingcross-service spansFreeze Gunstreak protectionS3 staged listsprefetched pre-gameSQS FIFO5-min dedup windowPush workersdedicated ECS clust…APNS / FCMpush gateways
Trace
A lesson request lands on the ALB, routes to the owning microservice (ECS, migrating to EKS), and checks caches before a single-row GetItem. Request tracing exposed a method querying DynamoDB repeatedly for the same data; caching it cut latency ~10%. The same table feeds the notification pipeline: campaign lists are prefetched to S3 pre-game, staged through SQS FIFO, and fanned out by a dedicated worker fleet.

What this teaches for the exam

Task 3.3 asks you to determine high-performing database solutions, and Duolingo is the canonical decision pattern. When a workload is key-value lookups by a known key, needs consistent single-digit-millisecond latency, and must scale horizontally to millions of users, the answer is DynamoDB, not a bigger RDS instance. Expect questions that describe exactly this shape (user profiles, game state, session data, leaderboards) and reward you for noticing that no relational features are used on the hot path. Remember the modeling corollary: DynamoDB performs when items are designed around access patterns, so 'store it as one item and read it with one GetItem' beats normalization. And know the caching ladder in front of any database: in-process caches, ElastiCache (Redis or Memcached) as a cache-aside layer, and DAX when you want a transparent, DynamoDB-specific read-through cache without changing application logic. Duolingo's 10 percent global latency win from caching one chatty method is the real-world version of the exam's favorite claim that caching relieves database read pressure.

Task 4.3 asks for cost-optimized database solutions, and every lever Duolingo pulled in their 20 percent cloud savings effort maps to an exam answer. They added missing TTL rules to DynamoDB tables that had accumulated stale data: DynamoDB TTL deletes expired items at no cost, which shrinks storage and is the exam's preferred answer for expiring session or ephemeral data. They tuned cache TTLs to slash downstream request volume: fewer reads means fewer consumed read capacity units, so caching is a cost control, not just a performance one. They switched a relational database to Aurora I/O-Optimized and saved several hundred thousand dollars a year: know that I/O-Optimized trades a higher instance/storage rate for zero per-I/O charges and wins when I/O is a large share of the bill. Round this out with the capacity mode decision the exam loves: on-demand for spiky or unknown traffic, provisioned (with auto scaling) for steady, predictable load where reserved throughput is cheaper. If a question pairs a read-heavy DynamoDB table with rising costs, look for the answer that adds a cache or a TTL before the one that adds capacity.

DuolingoCaching ladder and cost levers in front of DynamoDB
Caching ladder and cost levers in front of DynamoDBAWS CloudAWS RegionCaching ladder (cheapest reads first)Key-value store + capacityRelational cost lever (separate workload)read requestcheck in-process firstcache-aside (app-managed)read-through (transparent)miss: GetItemmiss: GetItemmiss: GetItemscale provisioned RCU/WCUconsumed RCU + storageper-I/O + instance costLearner's appread-heavy trafficServing fleetissues the readsIn-processcacherung 1: free, faste…ElastiCacherung 2: cache-asideDynamoDBAccelerator…rung 3: read-throughDynamoDBTTL on, capacity mo…ApplicationAuto Scalingprovisioned modeAuroraI/O-Optimizedrelational cost lev…CloudWatchcost + capacity sig…
Trace
The exam pattern: front a key-value table with a caching ladder (in-process, ElastiCache cache-aside, DAX read-through) so avoided reads are capacity you do not pay for, and pull cost levers on the store itself (TTL to expire stale items free, on-demand vs provisioned-with-auto-scaling capacity). Aurora I/O-Optimized is the separate relational lever that trades a higher rate for zero per-I/O charges.

More diagrams

Duolingo's learning data path: cache in front, DynamoDB behind
Duolingo's learning data path: cache in front, DynamoDB behindAWS CloudAWS RegionLesson serving pathNotification pipeline (Super Bowl scale)HTTPSread-through firstGetItem on missprefetch campaign listscampaign dispatchload listsAPNS / FCM pushLearner's appiOS / Android / webAPImicroservicesECS, 500+ servicesCache layerhot reads, TTL-tunedDynamoDB1 row per learner p…S3staged campaign lis…SQS FIFOdedup windowPush workersdedicated ECS clust…
Trace
The serving path is cache-aside: microservices on ECS check a cache before issuing a single-row GetItem to DynamoDB. The notification pipeline reads campaign user lists out of DynamoDB ahead of time, stages them in S3, and fans out through SQS FIFO queues to worker fleets that call APNS and FCM.

Sources

Practice what you just read

6 questions from this architecture

Loading…