cf.completefrontend
My progress
SYSTEM DESIGN · #138

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.

QuestionAnswer we assumeWhat it changes
Web only, or web plus native apps?Web app (desktop and mobile browsers), sharing the backend with native appsBrowser 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 webXP, 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 minutesThe whole lesson fits in one download and can be preloaded
Which exercise types?About 10 types now, more every quarterExercises must be data plus a registry of renderers, not one big component
Do lessons work offline?Yes, a few downloaded lessonsLessons, images and audio are stored on the device; results are queued
Speaking exercises?Yes, with a skip optionMicrophone permission, recording, server scoring, fallbacks
Are there prizes or rankings?Weekly leagues with promotion and demotionXP must be server-authoritative; the client only predicts
Who are the learners?Ages 13 and up, many on cheap Android phones and slow mobile dataSmall 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.

Wireframe of the lesson player. At the top: a close button, a progress bar about 60 percent full, and a hearts counter showing 4. Below: the instruction Write this in English, a play button for audio, and the German prompt Ich esse einen Apfel. In the middle: the exercise area, rendered by a component from the registry, holding the typed answer i'm eating an aple. At the bottom: a feedback bar saying Correct, with a typo note for apple, announced through a polite live region, plus a Skip button and a Continue button.Lesson player: /lesson/L-es-4-2Xhearts 4Write this in EnglishplayIch esse einen Apfelexercise component, from the registryi'm eating an apleCorrect! Typo: applelive region, politeSkipContinue
The top bar, the feedback bar and the Check or Continue button belong to the lesson engine and look the same for every exercise. Only the middle region changes per exercise type.

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?

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?

Schema plus one big switch

A lesson is JSON: a list of exercises, each with a type field.

One component renders every type with a switch.

Pros

  • Content is data; the server can choose and order exercises.
  • Simple to start.

Cons

  • All exercise code ships in one chunk, used or not.
  • The switch grows into a file every team edits.

Schema plus a registry of lazy components

Same JSON. A registry maps each type to a lazily imported component and a checker.

The engine owns the flow; each exercise component owns only its input.

Pros

  • A lesson downloads only the types it uses.
  • Adding a type means adding one module and one registry line.
  • The flow (check, feedback, retry) is written once.

Cons

  • Needs a versioned schema and a fallback for unknown types.
  • Each type must follow a shared contract.

Server-driven UI

The server sends a generic tree of layout primitives (text, image, button) instead of exercise types.

Pros

  • New screens without a client release.
  • Shared with native apps.

Cons

  • Rich inputs like tile building, matching and speaking don't fit generic primitives.
  • Hard to make each input accessible from a generic tree.

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.

ts
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
};
A lesson as data. Asset fields are URLs the preloader collects.

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.

tsx
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
}
The exercise contract and the registry: one line per exercise type, code-split by type.

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.

The lesson state machine. Loading preloads assets; READY moves to answering. In answering, CHECK moves to feedback, which shows right or wrong. CONTINUE goes back to answering for the next exercise in the queue. When the queue is empty, feedback moves to complete, which sends the result. If a wrong answer leaves 0 hearts, feedback moves to out of hearts, which offers practice or a wait. If the lesson JSON or a needed exercise chunk still fails after two retries, loading moves to load failed, which offers a retry or a way back to the path. A note says wrong answers are re-queued at the end and other events are ignored.loadingpreload assetsansweringdraft, Checkfeedbackright or wrongloadFailedretry or leaveoutOfHeartspractise or waitcompletesend resultREADYCHECKCONTINUEqueue emptyhearts = 0JSON failswrong: re-queued at endother events: ignored
Every event not drawn is ignored in that state, so a second 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.

Checking
translate: Ich esse einen Apfel

Pick an answer for exercise 1. Try a wrong one to see it come back at the end.

Lesson state: answering. Queue of 6 steps: 1, 2, 3, 4, 5, 6; now at step 1. Hearts 3. Solved 0 of 6. Checking mode: accepted.answeringfeedbackcompleteoutOfHeartsqueue: wrong answers go to the end123456#1 translateverdict: waitinghearts: 3solved: 0 of 6mode: accepted
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.

Preload timeline on 4G, from 0 to 800 milliseconds. The lesson JSON loads first, from 0 to about 120 ms. Then, in parallel, the exercise type chunks load until about 260 ms, four images until about 420 ms, and eight audio clips until about 600 ms. Audio is decoded from 600 to 700 ms. Exercise 1 appears at about 700 ms. On slow 3G the same bars stretch to about 8 seconds.lesson JSONtype chunksimages x4audio x8decode audioexercise 10 ms400 ms800 mson slow 3G the same bars stretch to about 8 s
The JSON must come first because it names everything else; after that, every asset loads in parallel. Exercise 1 waits for all of it, so no later step ever waits on the network. These timings are estimates for a 300 to 450 KB lesson.
  • Fetch audio into memory as an ArrayBuffer and decode it with AudioContext.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?

Exact match

Compare the typed string with one stored answer.

Pros

  • Trivial and predictable.

Cons

  • Marks correct answers wrong for a missing full stop or a capital letter.
  • Learners stop trusting the app.

Normalized match

Lower-case, trim, collapse spaces, remove punctuation, then compare.

Pros

  • Fixes the most common false negatives.
  • Cheap and deterministic.

Cons

  • Still one answer only; real translations have many forms.

Normalized match against many accepted answers

The lesson ships a set of accepted answers per exercise, written by course authors.

Normalize both sides and look for a match.

Pros

  • Accepts contractions, synonyms and other word orders the authors listed.

Cons

  • Accepted sets can be large; needs a compact format.
  • Answers are visible to anyone reading the network tab.

Many accepted answers plus typo tolerance

As above, and if nothing matches exactly, allow a small edit distance per word.

A typo is accepted but pointed out.

Pros

  • Feels fair: a typo in a long word is not a grammar mistake.
  • Teaches spelling without punishing it.

Cons

  • Must not accept a typo that makes a different real word (hat for hot).
  • Rules differ per language.

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 answer checking pipeline. The input I'm eating an aple goes to normalize, which lower-cases, applies NFC, and removes punctuation. Then match accepted compares with each accepted answer: an exact match gives correct; a match without accents gives accept with an accent note. If nothing matches, typo check allows one edit in words of 5 or more letters: one edit gives accept with a typo note, and no match gives wrong, showing the closest answer.inputI'm eating an aple.normalizelower, NFC, no punctmatch acceptedexact, then no accentstypo check1 edit, words of 5+correctaccept, accent noteaccept, typo notewrong, show closestexactno accents1 editno match
Each step can only be more lenient than the one before, never turn a right answer wrong. Here the learner typed aple, one edit from apple, so the answer is accepted with a typo note.

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.

ts
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) };
}
A text checker: normalize, match, then fall back to a per-word typo check.
  • 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 in and on are different answers, not typos.
  • Real-word guard: if the typo word is itself in the course vocabulary (hat vs hot), 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.

Decision Who decides XP and streak changes?

Client decides

The client computes XP and sends the new total.

Pros

  • Instant and works offline.

Cons

  • Anyone can send any number.
  • Leagues become meaningless.

Server only, client waits

The client sends the lesson result and shows a spinner until the server replies with XP.

Pros

  • Always correct.

Cons

  • The happiest moment of the lesson waits on a network round trip.
  • Breaks offline lessons completely.

Server decides, client predicts

The client sends the raw lesson result (answers, timings, local completion time).

It shows a predicted XP and streak right away using the same public rules.

The server computes the real values and the client reconciles.

Pros

  • Instant celebration.
  • Tamper resistant: the server only accepts facts, never totals.
  • Works offline: the prediction is shown, the result is queued.

Cons

  • Two copies of the rules must agree.
  • Rare corrections must be shown honestly.

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.

Sequence diagram for finishing a lesson, between User, Engine, Store, Outbox and API. The user presses the last Continue. The engine sends the lesson result, as facts, to the store. The store shows plus 15 XP and the streak flame to the user at once, as a prediction. The store appends the result with its attemptId to the outbox. The outbox posts the result to the API. The request times out or the device is offline. Later the outbox retries with the same idempotency key. The API replies 200 with the profile. The outbox gives the snapshot to the store, which clears the pending result.UserEngineStoreOutboxAPIlast Continueresult (facts)predicted XPqueue resultPOST resultsno responseretry same key200 + profilenew snapshot
The learner sees the reward in the same frame as the tap. The failure in the middle is harmless: the retry carries the same 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

Which requests are safe for the server to accept from the client in this design? Select all that apply.

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?

Server receive time in UTC

The day is the UTC date when the request arrived.

Pros

  • One clock, no trust issues.

Cons

  • A New York learner's evening lesson lands on tomorrow.
  • An offline lesson synced after midnight counts for the wrong day.

Server receive time in the account's time zone

Convert the arrival time to the time zone stored on the account.

Pros

  • Right for most online lessons.

Cons

  • Still wrong for offline lessons and slow networks around midnight.
  • Wrong while travelling until the account time zone updates.

Device completion time and time zone, validated by the server

The client records the completion instant (UTC milliseconds) and the IANA time zone at that moment.

The server derives the local day from both and checks them against what it knows.

Pros

  • Right for offline lessons, travel and late syncs.
  • The same rule runs on client and server, so the prediction matches.

Cons

  • The device clock can be wrong or changed, so the server needs limits.

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?".

ts
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"
The local day key, computed the same way on client and server.

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.

CaseWhat happensRule that handles it
Lesson finished offline at 23:50, synced at 00:10Counts for the earlier dayDay comes from completion time, not arrival time
Fly Tokyo to Los AngelesThe learner lives the same date twiceTwo lessons on one day key count once; no harm
Fly Los Angeles to TokyoThe date jumps ahead; a whole date can pass while in the airOur rule: one missing day, a time zone change, and under 24 hours since the last lesson keep the streak
Daylight saving dayThe day has 23 or 25 hoursDay keys from IANA zones, never hour arithmetic
Device clock set to tomorrowCompletion time is in the futureServer 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.

Local time 20:00 on 2026-03-10 in Asia/Kolkata, online, 0 lessons waiting. The client predicts a streak of 5, alive. The server counts 5, alive, with 0 freezes equipped.2026-03-10 20:00Asia/Kolkataonlineoutbox: 0 waitingfreezes: 0 of 2client predictsserver countsstreak 5alivestreak 5alive03-0603-0703-0803-0903-10green: server counted; warm: predicted; hatched: freeze
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?

None

Show an offline screen when the network is gone.

Pros

  • No storage or sync code.

Cons

  • Loses practice time and streak days for learners on poor networks, who are many of our users.

Cache whatever was fetched

A service worker caches responses as they pass (stale-while-revalidate).

Pros

  • Cheap to add.
  • The app shell and the last lesson work offline.

Cons

  • Only works for lessons already opened.
  • Results finished offline are lost unless stored somewhere.

Download lesson packs ahead, queue results

While online, download the next few lessons: JSON into IndexedDB, images and audio into the Cache API.

Finished results go into an outbox in IndexedDB and are sent when online.

Pros

  • A learner can do several real lessons offline.
  • Results keep their completion time, so streaks stay right.

Cons

  • Storage limits and eviction must be handled.
  • Downloaded lessons can go stale if the course changes.

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.connection reports 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 check navigator.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 Gone for an old version, the result still counts but the pack is replaced.
Timeline of an offline lesson in the Asia/Kolkata time zone. At 22:00, online, lesson packs are downloaded. At 23:20 the device goes offline. At 23:50 a lesson is finished and queued in the outbox. Then local midnight passes. At 00:10 the device is online again and the outbox is flushed. A note says the server computes day key 2026-03-10 from the completion time and adds one to the streak.22:00packs saved23:20goes offline23:50lesson queued00:10online, flushserver: day key2026-03-10streak + 1device zone: Asia/Kolkatalocal midnight
The result carries 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?

Browser speech recognition

Use the Web Speech API SpeechRecognition and compare the transcript with the target sentence.

Pros

  • No audio pipeline of our own.
  • Interim results allow live captions.

Cons

  • Not in every browser: Chrome has it (unprefixed since Chrome 139, webkitSpeechRecognition before) and Safari has it with the webkit prefix, but Firefox does not ship it.
  • Chrome's default engine is server-based: MDN notes the audio is sent to a web service, so it won't work offline and raises privacy questions. Chrome 139 added on-device recognition (processLocally), but only Chrome has it so far, and language packs may need a download.
  • A transcript is not a pronunciation score.

Record and score on our server

Record with getUserMedia and MediaRecorder, upload the clip with the expected sentence, get back a score per word.

Pros

  • Works the same in every modern browser.
  • The scoring model knows the target sentence and the course language.
  • We control privacy and retention.

Cons

  • Needs a scoring service and an upload (about 12 to 50 KB for a 3-second Opus clip, depending on the bitrate).
  • Does not work offline.

Server scoring, with browser recognition as an extra

Score on our server; where SpeechRecognition exists, use it only for live captions while speaking.

Pros

  • Consistent scores plus nicer feedback where available.

Cons

  • Two code paths, and audio may go to a browser vendor's server too: disclose it or skip it.

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.

Sequence diagram for a speaking exercise, between User, Exercise, Recorder and Scorer. The user taps the microphone. The exercise asks the recorder, which calls getUserMedia. The browser shows a permission prompt to the user, who allows it. The recorder returns a clip after 1.5 seconds of silence and stops the track. The exercise posts the clip and the expected sentence to the scorer. On success the scorer returns a score per word. On failure the request times out after 5 seconds. Either way the exercise shows feedback to the user; a failure counts as practice and costs no heart.UserExerciseRecorderScorertap micgetUserMediapermission promptAllowclip, track.stop()POST clip, textscores per wordor: timeout 5 sfeedback, no cost
The microphone is asked for only when needed, and released with 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 already granted or denied, 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 only audio/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 with track.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 notificationclick handler opens the lesson directly, not a marketing page.

Celebrations that never block

Decision How should the end-of-lesson celebration be animated?

CSS and SVG animation

Keyframes on transforms and opacity: a bouncing XP badge, confetti made of a few SVG shapes.

Pros

  • No extra library.
  • Runs on the compositor, cheap on low-end phones.

Cons

  • Hard to make rich character animation.
  • Designers can't edit it without engineers.

Lottie

Designers export After Effects animations as JSON; a player library renders them to SVG or canvas.

Pros

  • Huge designer ecosystem.
  • Plays a timeline exactly as designed.

Cons

  • The lottie-web player adds roughly 45 to 75 KB gzipped, and complex files are heavy to render as SVG.
  • Interactivity means scripting the timeline by hand.

Rive

Designers build animations with a built-in state machine in the Rive editor; a canvas or WebGL runtime plays them and reacts to inputs.

Pros

  • Interactive characters that react to state (happy, sad, talking) without engineering timelines.
  • Small files.

Cons

  • A WASM runtime to download: about 320 KB gzipped for the lite canvas build, and more for the full one.
  • A newer, smaller ecosystem.

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.

Architecture of the web client. Inside the browser tab: Routes and views lead to the Lesson engine, which drives Celebrations, the Audio loader, and the Game store. The Game store writes to IndexedDB, which holds packs and the outbox. Routes read from the Server cache. The Server cache and IndexedDB go through the Service worker. A Recorder sits beside them. Below, on the server side: the Service worker reaches the API and the CDN, and the Recorder reaches the Speech scorer.Browser tabRoutes, viewspath, lesson, leagueLesson enginemachine, registryCelebrationslazy, reduced motionServer cachepath, leagueGame storesnapshot + pendingAudio loaderpreload, decodeService workerIndexedDBRecorderresultServersAPI / BFFCDNSpeech scorer
The lesson engine in the middle has no network arrow: it only reads preloaded data and hands results to the game store. Lessons, assets and results go through the service worker and its caches; speech uploads are never cached, so they are drawn straight to the scorer.
PartResponsibilityWhy the boundary is here
App shell and routerRoutes for path, lesson, league, profile, settings; each route is its own chunkThe lesson route must not pay for league or profile code
Server state cachePath, profile snapshot, league table; fetched, cached, refetched on focusRead-mostly server data; a query library handles caching and retries
Lesson engineState machine, retry queue, registry, checkers; pure and synchronousNo network inside, so it is fast, testable and works offline
Asset loader and audio servicePreloads images and audio, decodes clips, plays on tapOne owner of the AudioContext and memory budget
Speech recorderPermission, MediaRecorder, silence detection, upload for scoringHardware and permission states isolated from the flow
Game state storePredicted XP, streak, hearts or energy: server snapshot plus pending resultsThe only place where prediction and reconciliation happen
Outbox and pack managerQueued results and downloaded lessons in IndexedDB; assets in the Cache APISurvives reloads and offline; shared by all tabs
Service workerApp shell cache, asset cache, push and notificationclick, background sync where supportedRuns when no tab is open
Celebration layerLazy animation runtime and sounds, reduced-motion awareHeavy 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.

Data flow for a lesson. The API and CDN send JSON, audio and images; the service worker fetches them cache first and stores a pack in IndexedDB with lessons and tickets. The pack's lesson goes to the lesson engine. The engine sends facts to the game store. The store queues the result in the outbox with an attemptId. The outbox posts it to the API, which computes XP and streak. The API sends the profile back to the game store. A note says views read the store and re-render.API + CDNJSON, audio, imagesService workercache firstLesson packsIndexedDB, ticketsLesson enginescreens, checksGame storesnapshot + pendingOutboxresults, attemptIdAPIXP, streakfetchpacklessonfactsqueuePOSTprofileviews read the storeand re-render
Reads flow along the top and down into the engine; writes flow back along the bottom. The only thing that crosses back into the store from the server is the authoritative profile, which replaces the prediction.

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.

EntityLives inOwnerNotes
Path (units, lessons, status)Server state cache, mirrored to IndexedDB for offlineServerRefetched after each lesson result
Profile snapshot (XP, streak, freezes, hearts)Game state store, persisted to IndexedDBServerShown with pending results applied
Lesson pack (JSON plus ticket)IndexedDB packs storeServer, issued to this userVersioned, expires
Lesson assets (audio, images)Cache API, one cache per pack versionCDNDeleted with the pack
Lesson session (queue, answers, state)Engine memory, checkpointed to IndexedDBClientLets a reload resume mid-lesson
Lesson resultIndexedDB outbox storeClient until acceptedAppend-only, deduped by attemptId
Settings (sound, speaking off until, reminder time)Server, cached locallyLearnerspeakingOffUntil is a timestamp
Current route and lesson idURLClient/lesson/:lessonId; back button leaves with a confirm
ts
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 client-side types for game state and queued results.
Where client data lives, in four places. Memory in the tab holds the path, the league, and the profile snapshot with pending results. IndexedDB holds lesson packs, the outbox, and the lesson session checkpoint. The Cache API holds audio clips and images. The URL holds the route, such as /lesson/L-es-4-2.Memory (tab)pathleaguesnapshot + pendingIndexedDBpacksoutboxsession checkpointCache APIaudio clipsimagesURL/lesson/L-es-4-2fast but lost on reload, or slower but kept
Memory is fast but gone on reload; IndexedDB survives reloads and offline, and also keeps a copy of the path and the profile snapshot so the app can start offline; the Cache API holds binary assets keyed by URL; the URL only names the screen.

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.

CallPurposeNotes
GET /v1/meProfile snapshot and settingsIncludes serverTime
GET /v1/path?course=es-enUnits and lessons with statusCacheable for a short time; ETag for cheap refetch
POST /v1/lessons/nextIssue the next lesson (or a review) with its ticketBody lists supportedTypes and flags like speakingOff
POST /v1/packsIssue N lessons for offline useReturns lesson JSON, tickets and asset URLs
POST /v1/lessons/{id}/resultsSubmit a finished lessonIdempotency-Key header equal to attemptId
POST /v1/speech/scoreScore a recorded clipmultipart/form-data: clip, MIME type, exercise id
GET /v1/leagues/currentThis week's group of about 30See the live-leaderboard guide for live variants
POST /v1/push/subscriptionsSave a Web Push subscription and reminder timeDeleted on opt-out
http
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": [] }
Submitting a lesson result; the server answers with authoritative numbers.

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 width and height so 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).

ExerciseTrapWhat we do
Word tilesDragging is hard or impossible for many usersTiles 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 pairsSelection state shown only by colourButtons with aria-pressed, a visible outline and a text state; matched pairs are announced
Listen and typeDeaf or hard-of-hearing learners can't do it"Can't listen now" plus a setting to turn listening exercises off
SpeakSpeech disabilities, no microphone, shared roomsSkip button and a setting; never blocks the lesson
Foreign textScreen readers read Spanish with an English voiceWrap target-language text in an element with lang="es" so the right voice is used
Timed challengesTime pressure excludes many usersWCAG 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.

  1. 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).
  2. 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.
  3. Minutes 12 to 20, answer checking and trust: the normalization pipeline, accepted sets, typo rules; then facts-not-totals, optimistic XP, idempotency keys.
  4. Minutes 20 to 30, streaks and offline: the day key, the 23:50 offline case, freezes, travel; lesson packs and the outbox.
  5. Minutes 30 to 40, architecture and API: the component diagram and the results endpoint, then speaking exercises and accessibility.
  6. 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?

Think first

Product wants a new exercise type next month: "tap the words you hear". What changes in the client?

Think first

At 20:00 local time in India, millions of results arrive within the same hour. What does the client do to help?

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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next