Riadh Mnasri
← Back to blog
4 min read

Spaced repetition: what the Leitner algorithm teaches about data structure

Kortex for Kotlin, Kafkex for Kafka, Vocably for English, Lingopack for Chinese: four apps with different content, but a single engine underneath, Leitner-style spaced repetition. What makes this problem interesting isn't the educational content, it's the data structure it imposes.

The principle, in one sentence

Each card belongs to a box (often numbered 1 to 5 or 7). A correct answer moves the card up a box; an incorrect one sends it back to box 1. The more advanced a box, the longer the interval before the next review. The system exploits gradual forgetting instead of fighting it: you only see a concept again just before you're about to forget it.

  Box 1          Box 2          Box 3          Box 4          Box 5
(1 day)   ──▶  (3 days)  ──▶  (7 days)  ──▶ (14 days)  ──▶ (30 days)
   ▲               │               │               │               │
   └───────────────┴───────────────┴───────────────┴───────────────┘
              an incorrect answer always sends the card back to box 1

The further right a card progresses, the longer the interval before its next review. A single mistake is enough to send it back to zero: that mechanism, not the content, does all the work.

Why it isn't just a counter

The temptation, when modeling this naively, is to store just a box number per card. The problem shows up as soon as you want to answer simple product questions: how many cards are due today? what's a user's actual progress on a given topic? how do you handle several decks with different review rhythms without recalculating everything on every render?

The structure that works well is a dated event per card (last review, result, computed next due date), rather than a simple mutable state. That turns an in-place update problem into a timeline query problem, much easier to evolve: adding progress statistics, or changing the interval calculation algorithm, doesn't require migrating existing data.

Concretely, that produces a structure close to this, rather than a plain box: number:

type ReviewEvent = {
  cardId: string;
  reviewedAt: Date;
  result: "correct" | "incorrect";
  boxAfter: number;
  dueAt: Date; // computed from boxAfter
};

With this shape, "how many cards are due today" becomes a simple query (dueAt <= now), and changing interval lengths only requires modifying the calculation function, never the data already recorded.

Naive counter vs dated event, what each question costs

Product questionWith box: numberWith a dated event per review
How many cards are due today?Full recalculation on every renderA direct query (dueAt <= now)
What's the actual progress on a topic?Requires recounting every timeReading from the already-stored history
Changing interval lengthsMigrating every existing cardEditing the calculation function only
Adding progress statisticsNo historical data availableAlready present in the event history

That last row is the one that justifies the choice in hindsight: a statistic nobody thought to expose on day one stays possible with an event history, while it's unrecoverable with a plain counter that overwrites its previous state on every review.

The real challenge: gamification without over-justification

On the apps built for my kids (in the same product family), the added constraint is motivating without a real reward: no exchangeable points, no badge worth anything outside the app. Spaced repetition helps here indirectly: seeing a card you thought you'd forgotten get answered right on the first try is intrinsic reward enough, if the algorithm is well calibrated to present cards at the right moment. A poorly calculated interval (too short, too long) ruins that effect and turns the app into a chore.

What generalizes from one app to the next

Kortex's revision engine, once extracted from its Kotlin-specific content, turned out to be directly reusable for Kafkex, then for Vocably and Lingopack. What changes from one app to the next is only the content (the cards) and the surface (language, visual theme): the progression logic is a problem solved once and for all. It's a good reminder that a product's real complexity isn't always where you expect it: here, it wasn't the educational content that was hard, it was the underlying data structure.