Design a gamified learning app like Duolingo
A web app where learners take short lessons made of many exercise types, earn XP, keep a daily streak, compete in weekly leagues, and practise offline or out loud. The hard parts are a lesson engine that renders any lesson from data and never stalls, fair answer checking, streaks that respect the learner's local day and can't be faked, offline lessons that still count, and speaking exercises that degrade gracefully.
The question
Design the web app of a gamified language learning product like Duolingo. A learner follows a path of units, opens a lesson of 10 to 15 short exercises (translate a sentence, pick an image, match pairs, build a sentence from word tiles, listen and type, speak a sentence), and gets instant feedback after each one. Finishing a lesson earns XP and keeps a daily streak alive. There are weekly leagues, a daily goal, and a limit on mistakes.
Walk me through how a lesson is described and rendered, how answers are checked, how XP and streaks stay correct across time zones and offline use, how speaking and listening exercises work in a browser, and how you keep it fast on cheap phones and usable with a keyboard or screen reader.
Real-life examples: Duolingo, Memrise, Babbel, Brilliant, Khan Academy, LeetCode daily streaks
Requirements: which learning app are we building?
A gamified learning app wraps practice in game rules: points, a daily streak, levels, rankings, and a limit on mistakes. Duolingo is the best known; Memrise, Babbel, Brilliant, Khan Academy and the LeetCode daily streak share the shape. The learning part is a sequence of tiny exercises. The game part is the state around them: how much XP you have, whether your streak is alive, where you stand this week.
Several neighbouring guides already cover parts of this product. We link them instead of repeating them, and this guide stays on what is special about a lesson app with game rules:
- online-judge: running and grading code, and checking answers only on the server.
- multiplayer-trivia: live quiz rounds with many players at once.
- live-leaderboard: a ranked table that updates in real time.
- offline-sync-engine: the general outbox, sync protocol and conflict rules for offline writes.
- audio-player: playback details such as buffering, seeking and the media session.
- notifications-center: the bell icon and the in-app notification list.
- form-builder: rendering a screen from a schema and a registry of field components.
- multi-tab-sync: keeping two open tabs in agreement.
Start with questions. Each answer moves the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Web only, or web plus native apps? | Web app (desktop and mobile browsers), sharing the backend with native apps | Browser limits on audio, microphone, storage and push shape the client |
| How many learners? | Tens of millions a day across all platforms (Duolingo reported about 48 million daily active users in mid-2025); we assume about 5 million on web | XP, streak and league writes are a large, spiky write load at local evening hours |
| How long is a lesson? | 10 to 15 exercises, 2 to 4 minutes | The whole lesson fits in one download and can be preloaded |
| Which exercise types? | About 10 types now, more every quarter | Exercises must be data plus a registry of renderers, not one big component |
| Do lessons work offline? | Yes, a few downloaded lessons | Lessons, images and audio are stored on the device; results are queued |
| Speaking exercises? | Yes, with a skip option | Microphone permission, recording, server scoring, fallbacks |
| Are there prizes or rankings? | Weekly leagues with promotion and demotion | XP must be server-authoritative; the client only predicts |
| Who are the learners? | Ages 13 and up, many on cheap Android phones and slow mobile data | Small bundles, low memory, offline tolerance, accessibility |
Functional requirements
- Show the learning path: units and lessons in order, which are done, which is next, which are locked.
- Play a lesson: show one exercise at a time, check the answer, show right or wrong feedback with the correct answer, and move on.
- Re-ask exercises the learner got wrong at the end of the lesson, until each one is answered correctly.
- Support at least these exercise types: translate (type), tap word tiles to build a sentence, select the matching image, match pairs, listen and type, and speak a sentence.
- Award XP at the end of a lesson, track a daily goal, and keep a daily streak with streak freezes.
- Limit mistakes with hearts or energy, and refill them over time or by practice.
- Show a weekly league of about 30 learners ranked by XP this week.
- Download a few upcoming lessons for offline use and count them when the device is back online.
- Send an opt-in daily reminder before the learner's streak would break.
Non-functional requirements
- Never stall mid-lesson: after the lesson starts, moving to the next exercise takes under 100 ms with no network request, and audio plays within 150 ms of a tap.
- Fast on cheap phones: path screen interactive in under 3 seconds on a mid-range Android over 4G; the lesson shell under 150 KB of compressed JavaScript, with each exercise type loaded on demand.
- Correct and fair game state: XP, streaks and league ranks are decided by the server; a learner can't award themselves XP from the browser console.
- Time zone honest: a day means the learner's local day, and a lesson finished at 23:50 counts for that day even if it syncs at 00:10.
- Accessible: every exercise is usable with a keyboard and a screen reader, colour is never the only feedback, and animations respect reduced motion.
- Kind by default: reminders are opt-in, respect quiet hours, and are easy to turn off.
Out of scope
- Course authoring tools and the content pipeline that writes the sentences (we only consume lessons as data).
- The machine learning that picks what to practise (we cover what the client needs from it, not the model training).
- Payments and subscriptions (only the effect: a subscriber may have unlimited hearts or energy).
Check your understanding · Exercise 1
Which answers to the scope questions change the client design the most? Select all that apply.
What makes this hard
On the surface it is a quiz: show a question, check an answer, add points. Five properties make it a real design problem.
- Many exercise types, one flow. A lesson mixes typing, tapping tiles, matching, listening and speaking. Each type has its own input, its own checking rule and its own accessibility needs, but the lesson flow around them (check, feedback, continue, retry) must be identical.
- Flow is everything. A lesson lasts about three minutes, and a spinner or late audio between two exercises breaks the rhythm that builds the habit.
- Game state with money-like value. XP decides league promotion, streaks are something learners care about deeply, and both can be gamed. The browser is untrusted, so the server decides, yet the learner expects to see +15 XP and the streak flame instantly.
- Days are local. Midnight, travel, daylight saving changes and offline lessons that sync after midnight all hit one question: which local day did this lesson belong to?
- Hardware in the loop. Speaking and listening exercises need the speakers, the microphone, a permission prompt and, often, a server to score speech. Any of these can be missing, blocked or busy, and the lesson must still finish.
The figure below names the parts of the main screen, the lesson player. Keep it in mind: most later chapters are about one of these regions.
Notice what is not on the list: huge data. A lesson is about 10 to 40 KB of JSON plus a few hundred KB of images and audio, and the learning path is a few hundred lessons. The difficulty is in timing, rules and trust, not volume.
That is why the key design tools here are a schema, a state machine, a preload step, and a clear split between what the client predicts and what the server decides.
Think first
A learner in New York finishes a lesson at 23:30 local time in summer. In UTC it is already 03:30 the next day. Which day should the streak count it for, and who should decide?
It counts for the learner's local day, because that is the day they experienced. A streak that broke because of UTC would feel like a bug.
The client knows the local time and the IANA time zone (Intl.DateTimeFormat().resolvedOptions().timeZone returns something like America/New_York). The server decides, from the completion time and the time zone sent with the result, with checks against obvious tampering. The streaks chapter works through the hard cases.
The lesson engine: a lesson is data
The first decision is how a lesson gets from the course team to the screen. The naive version is a React page per lesson (every content change needs a deploy), or one Lesson component with a long switch over exercise types, all in one bundle. It works for a demo. It breaks when there are 10,000 lessons, 10 exercise types, and a team shipping a new type every quarter: every type adds JavaScript that every learner downloads, and every change to the flow touches every type.
Decision How should a lesson be described and rendered?
What we pick: Schema plus a registry of lazily loaded components. Exercises are rich, interactive widgets, so they need real code, but which exercises appear and in what order is data the server decides. This is the config-driven-ui idea applied to lessons; the form-builder guide uses the same pattern for form fields.
The lesson schema
Each exercise is a member of a discriminated union: a TypeScript type where a type field tells you which shape the rest has. The checker only needs the data for that type.
type Exercise =
| { id: string; type: "translate"; prompt: string; promptAudio?: string;
accepted: string[]; lang: string }
| { id: string; type: "tiles"; prompt: string; tiles: string[];
accepted: string[][] }
| { id: string; type: "selectImage"; prompt: string;
choices: { id: string; label: string; image: string }[]; answerId: string }
| { id: string; type: "matchPairs"; pairs: [string, string][] }
| { id: string; type: "listenType"; audio: string; slowAudio?: string;
accepted: string[]; lang: string }
| { id: string; type: "speak"; text: string; audio: string; lang: string };
type Lesson = {
id: string;
schemaVersion: 3;
skillId: string;
exercises: Exercise[]; // ordered by the server
xpBase: number; // e.g. 10
};
The registry maps each type to a component loaded with a dynamic import(), plus a checker function. Exercise components never decide right or wrong and never move to the next exercise: they report the current answer up, and the engine does the rest.
type ExerciseProps<E extends Exercise, A> = {
exercise: E;
disabled: boolean; // true while feedback shows
audio: AudioService; // play(id), preloaded clips only
onAnswerChange(answer: A | null): void; // null = not ready to check
onSkip?(reason: "cantSpeak" | "cantListen"): void;
};
const registry = {
translate: { load: () => import("./ex/Translate"), check: checkText },
tiles: { load: () => import("./ex/Tiles"), check: checkTiles },
selectImage: { load: () => import("./ex/SelectImage"), check: checkChoice },
matchPairs: { load: () => import("./ex/MatchPairs"), check: checkPairs },
listenType: { load: () => import("./ex/ListenType"), check: checkText },
speak: { load: () => import("./ex/Speak"), check: checkSpeech },
} as const;
function isKnown(e: { type: string }): boolean {
return e.type in registry; // unknown types are skipped
}
A draft of null disables the Check button, so each exercise decides only when its input is complete (all tiles placed, an image chosen, a recording finished). Keeping the contract this narrow is what lets a team add an exercise type without touching the engine.
The lesson as a state machine
A lesson has a handful of clear phases, and bugs come from being in two at once: the learner presses Check twice, or the Continue button works while audio for the next exercise is still loading. Model it as a small state machine (see the state-machines lesson): a fixed set of states and the events that move between them. Anything not listed is ignored.
CHECK during feedback does nothing. Re-queuing a wrong exercise is part of the CHECK transition, not a separate state.The retry queue is the learning part. When an answer is wrong, the engine shows the correct answer and pushes the exercise onto the end of the queue. After the main list is done, the learner sees each missed exercise again until they get it right.
The progress bar only grows on correct answers, so it never goes backwards. A lesson of 6 exercises with 2 mistakes is played as 8 steps: 1, 2 (wrong), 3, 4, 5 (wrong), 6, 2, 5.
Mistakes may also cost a heart or energy (see the XP chapter). If the learner runs out mid-lesson, the machine goes to an outOfHearts state that offers practice or a wait, instead of silently failing. If the lesson JSON or a needed exercise chunk still fails after two retries, it goes to loadFailed, which offers a retry or a way back to the path. Try the flow in the lab: answer some exercises wrong and watch the queue and the state change.
Pick an answer for exercise 1. Try a wrong one to see it come back at the end.
Preload everything before exercise 1
To never stall mid-lesson, the engine walks the lesson JSON, collects every asset URL (images, normal and slow audio) and every exercise type, and fetches them all before showing exercise 1. A typical lesson (estimates): 12 exercises, 8 audio clips of 15 to 30 KB in Opus or AAC, 4 images of 20 to 40 KB in WebP, and 2 to 4 exercise chunks of 5 to 15 KB each. That is about 300 to 450 KB.
On 4G (about 10 Mbit/s) that takes well under a second. On slow 3G (about 400 kbit/s) it takes around 7 to 9 seconds, which is why we also prefetch the next lesson while the learner looks at the path or the end-of-lesson screen.
- Fetch audio into memory as an
ArrayBufferand decode it withAudioContext.decodeAudioData, or keep it in the Cache API, so play on tap has no network wait. The audio-player guide covers playback details. - Call
img.decode()on each image so the first paint of the exercise doesn't decode a large image on the main thread. - Show a short loading screen with real progress (bytes done of bytes total). If one image or clip fails after two retries, still start: mark that exercise as skippable rather than blocking the whole lesson.
- Keep a per-lesson memory cap (about 5 MB decoded audio). Release decoded buffers when the lesson ends.
Check your understanding · Exercise 2
A learner reports that pressing Enter twice quickly on a wrong answer skipped the feedback and lost a heart twice. Which design changes fix this class of bug? Select all that apply.
Checking answers fairly and giving the right feedback
The prompt is "Translate: Ich esse einen Apfel". The learner types i'm eating an apple, and the stored answer is I am eating an apple. A naive === says wrong, and the learner, who was right, loses a heart and some trust.
Free-text answers have many correct forms: case, punctuation, contractions, word order, synonyms, accents, and small typos. Checking is a product decision as much as code.
Decision How strict should the answer check be?
What we pick: Many accepted answers plus careful typo tolerance, checked on the client for instant feedback and offline use, then re-checked by the server when the lesson result arrives. Duolingo behaves this way from the learner's side: many translations are accepted, small typos pass with a note, and a learner can report an answer that should have been accepted, which feeds the accepted sets.
The pipeline has a fixed order, and each step can only make the result more lenient, never turn right into wrong. Accents are a special case: in Spanish si and sí are different words, so a missing accent is accepted with a note ("Pay attention to the accents") rather than ignored silently or failed.
type Verdict = { result: "correct" | "typo" | "accent" | "wrong"; best: string };
const norm = (s: string, lang: string) =>
s.toLocaleLowerCase(lang).normalize("NFC")
.replace(/[\p{P}\p{S}]/gu, " ").replace(/\s+/g, " ").trim();
const stripMarks = (s: string) => s.normalize("NFD").replace(/\p{M}/gu, "");
function checkText(input: string, accepted: string[], lang: string): Verdict {
const a = norm(input, lang);
for (const ans of accepted) {
const b = norm(ans, lang);
if (a === b) return { result: "correct", best: ans };
if (stripMarks(a) === stripMarks(b)) return { result: "accent", best: ans };
}
for (const ans of accepted) {
if (withinTypoBudget(a, norm(ans, lang))) return { result: "typo", best: ans };
}
return { result: "wrong", best: closest(a, accepted) };
}
- Typo budget: same number of words, and at most one edit (Levenshtein distance 1) in a word of 5 or more letters. Short words get no budget, because
inandonare different answers, not typos. - Real-word guard: if the typo word is itself in the course vocabulary (
hatvshot), treat it as wrong. - Compact accepted sets: a sentence can have dozens or hundreds of correct translations. Store them as a pattern of alternatives, such as
(I am|I'm) (eating|having) an apple, and expand or match it in the checker instead of shipping every string. - Tiles and choices are easier: the tile order is compared with accepted orders, and an image choice is compared by id. No normalization needed.
- Feedback shows the nearest accepted answer with the difference highlighted, so the learner sees exactly what to fix.
Check your understanding · Exercise 3
The accepted answer is Él está en el café. Which inputs should our checker accept, and with which note? Select all that apply.
Progress, XP and who decides
Two kinds of state surround the lessons: learning state (what the learner knows and should practise next) and game state (XP, streak, hearts or energy, and league place). Both live on the server. The client's job is to show them instantly and correctly, which pulls in two directions.
What to practise next is a server question
Spaced repetition means reviewing an item just before you would forget it. Duolingo published a model for this in 2016: half-life regression (Settles and Meeder, ACL 2016). It predicts the chance you still remember a word as p = 2^(-Δ/h), where Δ is the time since you last practised it and h is a learned half-life: after one half-life, recall drops to 50%. Each practice updates h from features such as how often you saw the word and how often you got it right.
The model needs every learner's full history and is trained on very large logs of practice, so it runs on the server. The client never computes it. It asks for the path and the next lesson and gets back an ordered list of exercises, and it sends back every answer so the model can learn. This split also keeps the client simple: the lesson engine does not care whether the exercises are new material or reviews.
XP: show it now, decide it on the server
At the end of a lesson the learner expects to see "+15 XP" and the streak flame light up within a frame. XP also decides who gets promoted in a weekly league, so it is worth cheating for: a script calling POST /xp with 10,000 would top every league.
What we pick: Server decides, client predicts. The client sends facts (which lesson, which answers, when it finished), never totals. The prediction is an optimistic update, as in the optimistic-updates lesson, and it is right in almost every case because the rules are simple and shared.
The server validates the facts before it trusts them: the lesson id was really issued to this user, it was not submitted before, the time spent is plausible (a 12-exercise lesson finished in 4 seconds is not), and the XP per day stays under a sane cap. An idempotency key (a random id the client creates once per lesson attempt) makes a retried request safe: the server returns the first result instead of awarding XP twice.
Idempotency-Key, so the server awards XP once, and the prediction stays on screen until the real numbers replace it.When the server's answer differs from the prediction, the client takes the server's numbers. Most differences are invisible (a bonus the client did not know about). A visible downgrade, such as a streak that did not count, gets a short, plain message on the next screen, never a silent change.
Hearts, energy and leagues
Hearts limit mistakes: each wrong answer costs one, and at zero the lesson stops until they refill over time or by practice. In 2025 Duolingo began replacing hearts with an energy system in its apps, where every exercise uses energy, right or wrong, and some can be won back. The details are still changing.
The client design is the same for both. The server sends the current count and the time of the next refill (nextRefillAt), the client shows a countdown from that timestamp, and it decrements locally during a lesson as a prediction. Never run the refill clock only on the client, because a learner can change the device clock.
Leagues are weekly groups of about 30 learners ranked by XP earned that week; the top few move up a league and the bottom few move down (Duolingo has 10 leagues, from Bronze to Diamond). The live table is the live-leaderboard guide's topic; here the league screen is fetched when opened and after each lesson, with no socket.
Check your understanding · Exercise 4
Streaks and the learner's local day
A streak is the number of days in a row on which the learner finished at least one lesson. Duolingo ends the streak day at midnight in the learner's own time zone. It sounds like one line of code. It is the most bug-prone rule in the product, because "today" depends on where the learner is, when the lesson finished, and when the server heard about it.
Decision Which clock decides the day a lesson belongs to?
What we pick: Device completion time and time zone, validated by the server. The server accepts a completion time only if it is not in the future, is not earlier than when the lesson was issued, and is not older than a policy limit (say 72 hours, an estimate). Within those limits, the learner's own clock is the fairest answer to "which day was it?".
function dayKey(epochMs: number, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
}).formatToParts(epochMs);
const get = (type: string) => parts.find((p) => p.type === type)?.value;
return `${get("year")}-${get("month")}-${get("day")}`; // sorts as text
}
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; // "Asia/Kolkata"
dayKey(Date.now(), tz); // "2026-10-22"
Use IANA names like America/New_York, never a fixed UTC offset: offsets change twice a year with daylight saving, and a day can be 23 or 25 hours long. Day keys hide that, because the streak rule only compares keys.
If the last active day key is yesterday's, the streak grows; if it is today's, nothing changes. If days are missing and enough streak freezes are equipped (one per missed day), the freezes are used up and the streak survives; otherwise it resets to 1, and the product may offer a paid streak repair for a short time. Duolingo lets most learners equip up to two freezes at a time.
| Case | What happens | Rule that handles it |
|---|---|---|
| Lesson finished offline at 23:50, synced at 00:10 | Counts for the earlier day | Day comes from completion time, not arrival time |
| Fly Tokyo to Los Angeles | The learner lives the same date twice | Two lessons on one day key count once; no harm |
| Fly Los Angeles to Tokyo | The date jumps ahead; a whole date can pass while in the air | Our rule: one missing day, a time zone change, and under 24 hours since the last lesson keep the streak |
| Daylight saving day | The day has 23 or 25 hours | Day keys from IANA zones, never hour arithmetic |
| Device clock set to tomorrow | Completion time is in the future | Server rejects times ahead of its own clock (with a small allowance) |
Duolingo does not publish its exact travel rules, so treat the table as one fair design, not a description of theirs. The travel rule is safe because, in one time zone, skipping a calendar day means a whole day passes between lessons. A gap that appears in under 24 hours, right after a zone change, came from the clock, not from the learner, and the rule is checked before freezes so a flight never costs one.
The lab below runs these rules. Finish lessons at different times, fly east or west, go offline at 23:50 and sync at 00:10, and equip a freeze. It shows what the client predicts and what the server finally counts.
It is 20:00 on 10 March in Mumbai. The streak is 5 and yesterday counted.
The server does not need a job that runs at every learner's midnight. It can evaluate the streak lazily: when a result arrives or the app asks for the profile, compare the last active day key with today's key in the learner's zone, and use freezes for the missing days then. The only thing that must run on a schedule is the reminder (see the reminders chapter), which fires before midnight, not after.
Check your understanding · Exercise 5
A learner in Mumbai (Asia/Kolkata) finishes a lesson offline at 23:55 on 10 March and the phone syncs at 00:20 on 11 March. They did nothing on 9 March, and they have one freeze equipped. What should happen? Select all that apply.
Offline lessons that still count
Learners practise on trains, on planes, and in places with bad data. Caching whatever the service worker happened to fetch is not enough: a lesson never opened is not cached, and a result finished offline has nowhere to go. Offline needs content downloaded ahead and results stored after.
Decision How much offline support should the web app have?
What we pick: Download lesson packs ahead and queue results. The pieces are standard: the app shell from the service worker cache (see service-worker-caching and pwa), lessons in IndexedDB (see indexeddb), and an outbox flushed when online (see background-sync and the offline-sync-engine guide). What is specific to this app is what goes into a pack and how a queued result proves it is real.
- What to download: the next 3 lessons on the path plus one review session, each with its audio and images, about 1 to 2 MB in total (an estimate). Refresh the packs after each completed lesson, on Wi-Fi when
navigator.connectionreports it (Chromium only; elsewhere, just keep packs small). - Signed lesson tickets: each pack carries a ticket from the server: user id, lesson id, issue time, and a signature. A result for a lesson that was never issued, or issued to someone else, is rejected.
- Storage: call
navigator.storage.persist()after the learner turns on offline lessons, and checknavigator.storage.estimate()before downloading. Safari deletes all script-writable storage (IndexedDB, Cache API, service worker registrations) after seven days of Safari use without any interaction with the site; web apps added to the home screen keep their own count, so this does not hit learners who use the installed app. Packs must be rebuildable and never the only copy of anything important. - Expiry: a pack has a version; if the server returns
410 Gonefor an old version, the result still counts but the pack is replaced.
completedAt and timeZone, so the server computes the day from when the lesson ended, not when it arrived. Arrival after midnight changes nothing.The outbox is simpler here than in a general sync engine, because lesson results are append-only facts: nobody edits a finished lesson, so there are no conflicts to merge. Each result has an attemptId, and the server ignores a second copy. Results are sent oldest first, because the streak rule depends on day order. The client keeps its predicted XP and streak until the server replies, and shows a small "3 lessons waiting to sync" note so the learner is not surprised later.
Hearts and energy are the awkward part offline. The client can't ask the server, so it predicts from the last known count and blocks new lessons at zero, like online. If the server later disagrees, the server wins, but it should never take away XP for a lesson already completed in good faith.
Check your understanding · Exercise 6
Which choices make offline lessons trustworthy for XP and streaks? Select all that apply.
Speaking and listening exercises in a browser
Audio exercises are where the browser's hardware rules meet the lesson flow. Each step can fail: no speakers, a blocked permission, a noisy room, a browser without the API, or a learner on a bus who simply can't talk right now.
Where the voices come from
Every sentence needs audio, at normal and slow speed. Human recordings sound best but cost studio time for each of millions of sentences. The browser's own speechSynthesis is free and works offline, but the voices differ by device, some languages have none, and quality varies.
The practical middle is to generate audio on the server with a high-quality text-to-speech voice when the course is built, and store it as small Opus or AAC files on a CDN. Treat those files like images: preloaded with the lesson and cached in offline packs. Keep speechSynthesis only as a last fallback for a missing file.
How speech gets scored
Decision How should a speaking exercise turn the learner's voice into a score?
What we pick: Record and score on our server. Scores must be consistent across browsers and grounded in the expected sentence, and only our own pipeline gives that. Offline, speaking exercises are skipped automatically and replaced with a non-speaking exercise from the pack.
track.stop() as soon as the clip ends. The red path is the important one: a scoring failure is our problem, so the learner keeps their heart.- Ask in context: request the microphone only when the first speaking exercise appears, after a short screen that says why. Where the Permissions API supports it,
navigator.permissions.query({ name: "microphone" })tells you if the answer is alreadygrantedordenied, so you can skip the prompt or show how to re-enable it. - Pick a format the browser can record:
MediaRecorder.isTypeSupported("audio/webm;codecs=opus")is true in Chromium, Firefox and Safari 18.4 or later; older Safari records onlyaudio/mp4. Feature-detect, and send the MIME type with the upload. - Stop on silence or after a limit: stop after about 1.5 seconds of silence (measured with an
AnalyserNode) or 10 seconds in total, then release the microphone withtrack.stop()so the browser's recording indicator turns off. - Fail softly: if scoring times out after about 5 seconds or the upload fails, accept the attempt as practice without XP for that exercise, and never cost a heart for an infrastructure failure.
Learners also need a way out. Duolingo shows buttons like "Can't speak now" and "Can't listen now" that turn those exercise types off for a while; it does not document a fixed duration, and learners report it has changed over time. Our design turns them off for 15 minutes, swaps in other exercises from the same lesson, and offers a setting to turn speaking or listening off until further notice.
The setting matters for accessibility too: a deaf learner or a learner with a speech disability should never be stuck behind an audio exercise.
Check your understanding · Exercise 7
The learner denied microphone access last week. They reach a speaking exercise today. What should the app do? Select all that apply.
Reminders, celebrations, and using game rules responsibly
Game rules work because they pull on real feelings: pride in a long streak, fear of losing it, the wish to beat a friend. That makes them powerful for building a habit and easy to abuse. The frontend owns most of the moments where this shows: the reminder that arrives at 21:00, the animation after a lesson, the screen that appears when a streak is about to break.
Streak reminders
On the web, reminders are Web Push notifications sent by the server through the browser's push service (the push-notifications lesson covers subscriptions, VAPID keys and the service worker side; the notifications-center guide covers the in-app list). Three product rules shape the client.
- Ask at a meaningful moment: offer reminders after the first finished lesson ("Want a nudge at 20:00 tomorrow?"), never on first visit. A browser denies the prompt for good once the learner blocks it, so a wasted prompt is lost forever. On iOS and iPadOS, web push works only for web apps added to the home screen (iOS and iPadOS 16.4 and later).
- Send at a learned time, before the deadline: the server picks a time from when the learner usually practises, in their time zone, and only if today's lesson is not done yet. One reminder per day at most, never in quiet hours (for example 22:00 to 08:00 local), and fewer reminders after several ignored days. Duolingo has published research on choosing reminder texts with a bandit algorithm (Yancey and Settles, KDD 2020).
- Make off easy: every reminder links to a settings page where one switch turns them off, and the service worker's
notificationclickhandler opens the lesson directly, not a marketing page.
Celebrations that never block
Decision How should the end-of-lesson celebration be animated?
What we pick: CSS for small feedback inside the lesson (correct, wrong, progress bar), and a richer runtime only for characters and the end screen. Duolingo's blog describes using Rive's state machine to lip-sync its characters, with files small enough for Android, iOS and the web, which fits the state-driven style. Whatever the runtime, load it lazily after the first exercise renders, and never let an animation delay the Continue button.
Respect prefers-reduced-motion: reduce by replacing motion with a static image and a short text ("Lesson complete, +15 XP"). Sound effects follow the browser's autoplay rules: they can play after a user gesture such as pressing Check, which every lesson has. Still, give a visible sound switch, keep effects short, and never play them over the lesson audio or a screen reader.
Check your understanding · Exercise 8
Which reminder designs respect the learner while still helping the habit? Select all that apply.
Architecture, data model and API
With the hard decisions made, the client falls into a few parts with clear jobs. The guiding rule: the lesson engine runs entirely from local data once a lesson starts, and everything that talks to the network sits outside it, behind a store or a queue. That rule is what makes "never stall mid-lesson" and "works offline" the same feature.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| App shell and router | Routes for path, lesson, league, profile, settings; each route is its own chunk | The lesson route must not pay for league or profile code |
| Server state cache | Path, profile snapshot, league table; fetched, cached, refetched on focus | Read-mostly server data; a query library handles caching and retries |
| Lesson engine | State machine, retry queue, registry, checkers; pure and synchronous | No network inside, so it is fast, testable and works offline |
| Asset loader and audio service | Preloads images and audio, decodes clips, plays on tap | One owner of the AudioContext and memory budget |
| Speech recorder | Permission, MediaRecorder, silence detection, upload for scoring | Hardware and permission states isolated from the flow |
| Game state store | Predicted XP, streak, hearts or energy: server snapshot plus pending results | The only place where prediction and reconciliation happen |
| Outbox and pack manager | Queued results and downloaded lessons in IndexedDB; assets in the Cache API | Survives reloads and offline; shared by all tabs |
| Service worker | App shell cache, asset cache, push and notificationclick, background sync where supported | Runs when no tab is open |
| Celebration layer | Lazy animation runtime and sounds, reduced-motion aware | Heavy and optional; must never block the flow |
The game state store deserves a note. It holds the last server snapshot (xp, streak, lastActiveDay, freezes, hearts, nextRefillAt) and the list of pending results from the outbox, and it derives what to show by applying the pending results to the snapshot with the shared rules. When the server replies, the snapshot changes and the pending list shrinks, so the prediction updates itself. There is never a second, hand-edited copy of XP to drift out of sync.
Two open tabs both reading the outbox could send the same result twice. The attemptId makes that harmless on the server, and a Web Lock (navigator.locks.request("outbox", ...)) lets only one tab flush at a time; the multi-tab-sync guide covers the general pattern. The next figure follows lessons in and results out.
Check your understanding · Exercise 9
Why does the lesson engine have no network calls inside it? Select all that apply.
Data model: what lives where
Each piece of client data has one home, chosen by who owns it and how long it must live. Server-owned data that the client only reads goes in the server state cache. Data that must survive a reload or work offline goes in IndexedDB. Data that only matters during one lesson stays in memory inside the engine.
| Entity | Lives in | Owner | Notes |
|---|---|---|---|
| Path (units, lessons, status) | Server state cache, mirrored to IndexedDB for offline | Server | Refetched after each lesson result |
| Profile snapshot (XP, streak, freezes, hearts) | Game state store, persisted to IndexedDB | Server | Shown with pending results applied |
| Lesson pack (JSON plus ticket) | IndexedDB packs store | Server, issued to this user | Versioned, expires |
| Lesson assets (audio, images) | Cache API, one cache per pack version | CDN | Deleted with the pack |
| Lesson session (queue, answers, state) | Engine memory, checkpointed to IndexedDB | Client | Lets a reload resume mid-lesson |
| Lesson result | IndexedDB outbox store | Client until accepted | Append-only, deduped by attemptId |
| Settings (sound, speaking off until, reminder time) | Server, cached locally | Learner | speakingOffUntil is a timestamp |
| Current route and lesson id | URL | Client | /lesson/:lessonId; back button leaves with a confirm |
type ProfileSnapshot = {
xpTotal: number;
xpThisWeek: number;
dailyGoalXp: number; // e.g. 20
streak: number;
lastActiveDay: string | null; // "2026-10-21", in timeZone
timeZone: string; // IANA, e.g. "Asia/Kolkata"
freezesEquipped: number; // 0 to 2
hearts: number | null; // null when unlimited
nextRefillAt: string | null; // ISO time from the server clock
serverTime: string; // to estimate clock skew
};
type LessonResult = {
attemptId: string; // crypto.randomUUID(), once per attempt
lessonId: string;
ticket: string; // signed by the server when issued
answers: { exerciseId: string; given: string; verdict: string; ms: number }[];
startedAt: number; // epoch ms, device clock
completedAt: number; // epoch ms, device clock
timeZone: string;
};
The serverTime field lets the client estimate how far its clock is from the server's (skew = serverTime - Date.now() at receipt) and show refill countdowns that agree with the server, while still recording completedAt on the device clock for the day key. A large skew (say over 10 minutes) is worth logging: it often explains streak complaints.
Check your understanding · Exercise 10
Where should the in-progress lesson session (current index, retry queue, answers so far) live? Pick one.
Interface: the API
The API is plain REST over HTTPS, versioned, with JSON bodies. Nothing here needs a socket: the league table is fetched when opened, and everything else is request and response. A backend-for-frontend can shape responses for the web client, but the shapes below are what the client sees.
| Call | Purpose | Notes |
|---|---|---|
GET /v1/me | Profile snapshot and settings | Includes serverTime |
GET /v1/path?course=es-en | Units and lessons with status | Cacheable for a short time; ETag for cheap refetch |
POST /v1/lessons/next | Issue the next lesson (or a review) with its ticket | Body lists supportedTypes and flags like speakingOff |
POST /v1/packs | Issue N lessons for offline use | Returns lesson JSON, tickets and asset URLs |
POST /v1/lessons/{id}/results | Submit a finished lesson | Idempotency-Key header equal to attemptId |
POST /v1/speech/score | Score a recorded clip | multipart/form-data: clip, MIME type, exercise id |
GET /v1/leagues/current | This week's group of about 30 | See the live-leaderboard guide for live variants |
POST /v1/push/subscriptions | Save a Web Push subscription and reminder time | Deleted on opt-out |
POST /v1/lessons/L-es-4-2/results
Idempotency-Key: 6f1c2b7e-5d0a-4f8e-9a44-1e3c9b2d7f10
Content-Type: application/json
{ "ticket": "eyJ...", "startedAt": 1792690200000, "completedAt": 1792690380000,
"timeZone": "Asia/Kolkata",
"answers": [{ "exerciseId": "e1", "given": "i am eating an apple", "verdict": "correct", "ms": 5400 }] }
HTTP/1.1 200 OK
{ "xpAwarded": 15, "dayKey": "2026-10-22", "dailyGoalMet": true,
"profile": { "xpTotal": 1535, "streak": 42, "lastActiveDay": "2026-10-22",
"freezesEquipped": 1, "hearts": 4, "serverTime": "2026-10-22T17:33:05Z" },
"corrections": [] }
Errors use one shape, { "error": { "code": "TICKET_EXPIRED", "message": "...", "retryable": false } }, so the outbox can decide what to do without reading English. Retryable errors (503, timeouts) stay in the outbox with exponential backoff. Non-retryable ones (TICKET_INVALID, TOO_OLD) leave the outbox and are reported to error tracking.
A replayed attemptId returns the original 200 body, so the client handles it like any success. The corrections list carries the rare cases where the server disagrees with the client's verdicts, so the client can explain them. The exercise component contract is the other interface, and the lesson engine chapter already defines it.
Fast on cheap phones, usable by everyone
Many learners use low-end Android phones on slow, metered data. Set budgets and measure them in the field. For the path screen: LCP (Largest Contentful Paint) under 2.5 seconds and INP (Interaction to Next Paint) under 200 ms at the 75th percentile, the "good" thresholds for Core Web Vitals. Inside a lesson, the numbers that matter are our own: time from tapping a lesson to exercise 1, transitions under 100 ms, and audio start under 150 ms after a tap.
Bundles, assets and the PWA
- Split by route and by exercise type (see code-splitting): the shell, the path route, the lesson engine, and one chunk per exercise type. When a lesson is issued, prefetch the chunks for the types it contains with
import()before the learner taps Start. - Keep heavy things optional: the animation runtime, the speech recorder and the league screen load only when needed. A lesson with no speaking exercise never downloads recording code.
- Fonts per script: Latin text uses one small subset; Japanese, Chinese or Korean courses rely on system fonts rather than downloading multi-megabyte web fonts.
- Audio and images: Opus or AAC at about 32 to 48 kbit/s mono is clear enough for speech; images as WebP or AVIF with explicit
widthandheightso nothing shifts. - Installable PWA: a web app manifest and service worker let learners add the app to the home screen (see pwa), which also unlocks web push on iOS and gives the installed app its own 7-day counter in Safari, so stored packs are not deleted while the learner keeps using it.
Accessible exercises
Each exercise type has its own accessibility trap, and the shared engine fixes the common ones once. The engine owns focus: after Check, focus moves to the feedback heading so a screen reader reads "Correct" or "Not quite. Correct answer: ...", and after Continue it moves to the new exercise's prompt. Feedback is also announced through a polite live region (see aria-patterns), and it always uses an icon and words, never green or red alone (see visual-accessibility).
| Exercise | Trap | What we do |
|---|---|---|
| Word tiles | Dragging is hard or impossible for many users | Tiles are buttons: tap or press Enter to move a tile to the answer line and back; drag is an extra. This meets WCAG 2.5.7 Dragging Movements |
| Match pairs | Selection state shown only by colour | Buttons with aria-pressed, a visible outline and a text state; matched pairs are announced |
| Listen and type | Deaf or hard-of-hearing learners can't do it | "Can't listen now" plus a setting to turn listening exercises off |
| Speak | Speech disabilities, no microphone, shared rooms | Skip button and a setting; never blocks the lesson |
| Foreign text | Screen readers read Spanish with an English voice | Wrap target-language text in an element with lang="es" so the right voice is used |
| Timed challenges | Time pressure excludes many users | WCAG 2.2.1 Timing Adjustable: offer an untimed mode or let users extend time |
Keyboard support goes beyond focus (see keyboard-navigation): Enter checks and continues, number keys pick the 1st to 4th choice, and every shortcut is listed in a help dialog. Test each exercise type with a screen reader on both desktop and mobile, because a tile exercise that works with VoiceOver on a Mac can still trap focus on Android with TalkBack.
Finally, observe the things the learner feels. Log lesson start time, every stall (a transition over 300 ms), audio play latency, failed asset loads, outbox age when flushed, and how often the server corrects a predicted streak. A spike in streak corrections after a release usually means the client and server rules have drifted apart.
Check your understanding · Exercise 11
A keyboard-only learner reaches a "build the sentence" exercise with word tiles. What does a good design provide? Select all that apply.
Running it in the interview
In 45 minutes you can't cover every chapter, so choose. Interviewers asking for "a Duolingo" usually want to see two things: that you can turn many exercise types into one clean engine, and that you know game state needs a trust boundary. Streak time zones and offline lessons are the best places to show depth.
- Minutes 0 to 5, scope: ask about platforms, exercise types, offline, speaking, and whether XP has stakes (leagues). Say out loud which neighbouring problems you will treat as black boxes (leaderboard, push, sync engine).
- Minutes 5 to 12, the lesson engine: draw the lesson JSON with three exercise types, the registry, and the state machine with the retry queue. Mention preloading and the 100 ms transition budget.
- Minutes 12 to 20, answer checking and trust: the normalization pipeline, accepted sets, typo rules; then facts-not-totals, optimistic XP, idempotency keys.
- Minutes 20 to 30, streaks and offline: the day key, the 23:50 offline case, freezes, travel; lesson packs and the outbox.
- Minutes 30 to 40, architecture and API: the component diagram and the results endpoint, then speaking exercises and accessibility.
- Minutes 40 to 45, wrap up: performance budgets, what you would measure, what you would build next.
Draw the state machine early. It is small, it makes the retry queue and the double-submit bug obvious, and it gives the interviewer a map for the rest of the conversation. Expect these follow-ups.
Think first
A learner writes a script that replays the same POST .../results request 500 times. What happens?
Nothing extra. The Idempotency-Key (the attemptId) makes every replay return the first response, and the signed ticket can be used once.
If they generate new attempt ids, the tickets still fail: each ticket was issued for one attempt. A per-day XP cap and plausibility checks on timings catch the rest, and suspicious accounts can be held out of league promotion.
Think first
Product wants a new exercise type next month: "tap the words you hear". What changes in the client?
A new member of the Exercise union, a new component that follows the exercise contract, a checker, and one registry line. The engine, state machine, preloader (which already collects audio fields) and accessibility rules for focus and feedback are reused.
The client adds the new type to supportedTypes, so the server only sends it to clients that can render it.
Think first
At 20:00 local time in India, millions of results arrive within the same hour. What does the client do to help?
It spreads load: queued offline results flush with a random delay of a few seconds, retries use exponential backoff with jitter, and the profile is not refetched separately because the results response already contains it.
The path refetch uses ETag so unchanged data costs a 304. Assets come from the CDN and the service worker cache, not the API.
Summary
- A lesson is data: an ordered list of typed exercises rendered by a registry of lazily loaded components, with one engine owning check, feedback, retry and continue.
- Model the lesson as a small state machine with a retry queue; double submits and double heart losses become impossible by construction.
- Preload everything a lesson needs (JSON, exercise chunks, images, decoded audio) before exercise 1, and prefetch the next lesson, so nothing waits on the network mid-lesson.
- Check answers on the client for instant feedback with normalization, many accepted answers and narrow typo rules, then re-check on the server.
- The client sends facts, never totals: the server decides XP, streaks and hearts, and the client shows an optimistic prediction that reconciles to the server's numbers.
- A streak day is a local day key from the device completion time and IANA time zone, validated by the server, so offline and late-synced lessons count for the right day.
- Offline lessons are signed lesson packs in IndexedDB plus assets in the Cache API, and an append-only outbox of results deduped by attempt id.
- Speaking exercises record with MediaRecorder and score on our server, ask for the microphone in context, and always offer a skip that never costs a heart.
- Game rules are powerful: opt-in reminders at a learned time, an easy off switch, reduced motion, and accessible alternatives for dragging, audio and time limits.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- A Trainable Spaced Repetition Model for Language Learning (Settles and Meeder, ACL 2016) Duolingo's half-life regression model for what to practise next.
- A Sleeping, Recovering Bandit Algorithm for Optimizing Recurring Notifications (Yancey and Settles, KDD 2020) How Duolingo chooses reminder texts without wearing them out.
- Duolingo blog: how the streak builds a habit Streak freezes, and why up to two can be equipped.
- Duolingo blog: how Leaderboards work Weekly leagues, the 10 tiers, and how groups are matched.
- Duolingo blog: how the world characters got lip sync with Rive Why a state-machine animation runtime suits interactive characters.
- MDN: MediaRecorder Recording microphone audio, formats and events.
- MDN: SpeechRecognition The Web Speech API, its support, and on-device processing.
- MDN: Intl.DateTimeFormat Formatting dates in an IANA time zone for local day keys.
- MDN: StorageManager.persist() Asking the browser not to evict offline lesson packs.
- WebKit: Full third-party cookie blocking and more Safari's 7-day cap on script-writable storage and the home screen exemption.
- WebKit: Updates to storage policy Quotas, eviction, and when Safari grants persistent storage.
- Understanding WCAG 2.2: Dragging Movements (2.5.7) Why word tiles need a tap and keyboard alternative.
- Understanding WCAG 2.2: Timing Adjustable (2.2.1) What timed challenges must offer.
Concepts behind it
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.