devto 2026-08-02 원문 보기 ↗
Disclosure: I build VibeLing, a vocabulary app. This post is about the scheduler inside it — what it does, what we deliberately didn't build, and the thing that broke once real people started using it.
Every spaced repetition app has one function at its core: given a word and everything that has happened to it so far, return a date. Show it again on that date. Everything else — the card UI, the audio, the streak counter — sits on top of that one decision.
We got that function wrong in an interesting way, so here is how it works now and what it cost to get there.
Two numbers and a state machine. That's the whole model.
Word {
state: LEARNING | REVIEW
correct: int // successful answers in LEARNING
interval: int // days, only meaningful in REVIEW
difficulty: float // starts at 1.0
due: date
}
A new word starts in LEARNING. It leaves LEARNING only after nine correct answers, and we cap it at three per day, spread across three different exercise types — pick the translation, fill the gap in a sentence, say it out loud. So a word takes a minimum of three days to graduate, and it has to survive three different ways of being asked before we believe it.
Once it graduates, it moves onto a doubling ladder:
1 → 2 → 4 → 8 → 16 → 32 → 64 → 128 → 256 → 512 days
Answer correctly, step up. Answer wrong, drop back — badly enough, and the word returns to LEARNING and has to earn its nine answers again.
That's it. No half-life model, no per-user parameter fitting.
The obvious question for anyone who has read about this: why not FSRS, the algorithm Anki moved to? It models memory properly — every card carries difficulty, stability and retrievability, and the scheduler predicts the probability you'd recall the card today and picks the day that probability crosses your target retention.
It's better. We didn't use it, for two reasons that had nothing to do with the algorithm being wrong.
It needs review logs we didn't have. FSRS earns its accuracy by fitting parameters to a real review history. On day one we had no history, so we'd have shipped default parameters and a much more complicated system, in exchange for approximately nothing.
A doubling ladder is debuggable. When a user writes in saying a word keeps coming back too often, I can read their word's history in about ten seconds and say exactly why. With a fitted model I'd be squinting at three floats. At the stage where you're still discovering what your product does wrong, being able to explain the system out loud to a confused user is worth more than a few percentage points of retention.
I'd revisit this now that there are logs. That's an honest "we'd probably do it differently today", not a claim that the simple thing is better.
The one place we didn't go simple is grading. Most flashcard UIs collapse an answer into pass/fail, which discards nearly everything the session just told you. Two users both answered correctly; one of them took nine seconds and used a hint.
So difficulty starts at 1.0 and moves on more than correctness:
updateDifficulty(word, answer):
if answer.attempt > 1: difficulty += a // got it, but not first try
if answer.usedHint: difficulty += b
if answer.timeMs > slowFor(word.exerciseType):
difficulty += c
if answer.type == SPEECH and answer.recognitionScore < threshold:
difficulty += d
recentErrorRate = errors(word, lastSessions = 10) / attempts(word, lastSessions = 10)
difficulty = blend(difficulty, recentErrorRate)
return clamp(difficulty, MIN, MAX)
nextInterval(word):
return round(ladderStep(word.interval) / word.difficulty)
I'm writing the constants as a, b, c, d on purpose. They were tuned by hand and by feel, and dressing them up as derived values would be a lie. What matters is the shape: a slow, hinted, second-attempt "correct" is not the same event as an instant one, and the interval it earns should be shorter.
The speech signal is the one I'd defend hardest. Pronunciation exercises run through speech recognition, and a low recognition score on a word the user "knows" is a good early warning that they know it by sight only. Sight-only knowledge is exactly the kind that collapses in conversation.
The rolling error rate over the last 10 sessions is the counterweight. Any single answer is noisy — people tap the wrong button on the bus. A word has to be consistently hard before the coefficient moves much.
Here's the part I'd want to read in someone else's post.
The scheduler was fine. The queue was not.
Nothing in the model above limits how many words come due on the same day. Users add words from shows and articles, and they add them in bursts — forty words on a Sunday evening, because they finally sat down with a series. Three days later, all forty graduate LEARNING together. Sixteen days later they come due together again. The ladder is deterministic, so bursts stay in formation forever and reinforce each other every time they land.
Individually every one of those due dates is correct. Collectively, the app opens on a Tuesday and says you have 380 words to review.
What people do with that number is not "review 380 words". They close the app. And a spaced repetition system that doesn't get opened is worse than no system, because all those intervals keep expiring in the background and the number keeps climbing. The user comes back on Friday to 520.
We could see it in the data: sessions weren't getting longer before people churned. They were getting shorter, then stopping. The backlog wasn't overworking anyone. It was just demoralising.
The obvious fix is to limit new words per day, and it's the one most apps reach for. We didn't, for a product reason: the moment someone hears a word in a show and wants to keep it is the moment they're most engaged. Telling them "you've hit today's limit" punishes the exact behaviour the app exists to encourage.
So we capped the other end. A session is always the same size — ten words — and the full queue is never displayed.
buildSession(user):
due = wordsDueFor(user) // may be 500 long
ranked = sortBy(due, [ overdueDays desc, difficulty desc ])
return take(ranked, SESSION_SIZE) // always 10
Two things changed as a result.
The visible number stopped being a threat. There is no 380 anywhere in the UI, only a session of ten and a plan of three sessions for today. The work is the same work; it just no longer arrives as a wall.
And queue ordering turned into the real problem — which is the interesting consequence. When you show everything, ordering barely matters, because the user gets through it all or doesn't. When you show ten out of five hundred, those ten slots are the entire product. Pick badly and a word can sit overdue for weeks while easier words cycle in front of it. That's why the sort is by overdue days first, difficulty second: the most rotten thing in the queue goes first, and the ones the user is actually shaky on get priority over the ones they'd have breezed through.
The backlog still exists. It drains slowly instead of being paid off in one heroic sitting, and that turns out to be fine — it's the same tradeoff the ladder makes, applied to the queue instead of a single word.
Two things I don't have a good answer for.
Long absences. Someone disappears for two months and comes back to a queue where every interval has blown past. Resetting everything to LEARNING is too punishing; ignoring the gap means their first session is full of words they've genuinely lost. Right now the overdue-first sort papers over it. It isn't a real answer.
Bursts still travel together. Capping the session hid the symptom but didn't break up the convoy — words added on the same evening still march up the ladder in formation. Adding a small jitter to each interval would spread them out, and the reason we haven't is unsatisfying: nobody has complained about it since the sessions got bounded, and there's always something more visible to fix.
If you want the non-technical version of the same ideas — what spaced repetition is doing to memory and why the interval should land right when a word is about to fade — I wrote a plain-language walkthrough of spaced repetition for the blog.
And if you'd rather poke at the scheduler from the outside than read pseudocode about it, you can add ten Spanish words and watch what it does with them.
If you've built one of these: how do you handle a user who vanishes for two months? That's the case I keep going back and forth on.