What Is Spaced Repetition?
Here's the problem with traditional studying: you review everything equally, whether you know it or not. That's inefficient.
Spaced repetition flips this. Cards you struggle with come back sooner. Cards you know well get pushed further into the future. Over time, you spend your energy exactly where it's needed.
The SM-2 algorithm (developed by Piotr Wozniak in 1987) is the math behind this. It's the same core logic used in Anki β one of the most popular study tools in the world.
π The SM-2 Formula Explained
Every card tracks three values:
| Variable | Meaning |
|---|---|
repetitions |
How many times you've answered correctly in a row |
easeFactor (EF) |
How "easy" the card is (starts at 2.5, min 1.3) |
interval |
Days until next review |
After each review, the user rates their recall from 0 to 5:
- 0β2 = Failed (didn't remember)
- 3 = Barely remembered
- 4 = Correct with some hesitation
- 5 = Perfect recall
Here's the core logic:
function sm2(card, quality) {
// quality: 0-5 rating from the user
if (quality < 3) {
// Failed β reset streak, review again soon
card.repetitions = 0;
card.interval = 1;
} else {
// Passed β calculate next interval
if (card.repetitions === 0) {
card.interval = 1;
} else if (card.repetitions === 1) {
card.interval = 6;
} else {
card.interval = Math.round(card.interval * card.easeFactor);
}
card.repetitions += 1;
}
// Update ease factor
card.easeFactor = card.easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
// Ease factor never drops below 1.3
if (card.easeFactor < 1.3) card.easeFactor = 1.3;
// Set next review date
card.nextReviewAt = new Date();
card.nextReviewAt.setDate(card.nextReviewAt.getDate() + card.interval);
return card;
}
That's it. The whole algorithm fits in about 20 lines.
How I Modeled This in the Database
In my Laravel backend, each card record in the cards table stores its SM-2 state:
// database/migrations/create_cards_table.php
Schema::create('cards', function (Blueprint $table) {
$table->id();
$table->foreignId('deck_id')->constrained()->cascadeOnDelete();
$table->text('front');
$table->text('back');
// SM-2 fields
$table->integer('repetitions')->default(0);
$table->float('ease_factor')->default(2.5);
$table->integer('interval')->default(1);
$table->timestamp('next_review_at')->nullable();
$table->timestamps();
});
When a study session ends, the frontend sends the user's rating to the API, and the backend runs the SM-2 calculation and updates these fields.
π The Study Session Flow
Here's how a full study session works end-to-end:
1. Fetch due cards (Laravel Controller)
// CardController.php
public function getDueCards($deckId)
{
$cards = Card::where('deck_id', $deckId)
->where(function ($query) {
$query->whereNull('next_review_at')
->orWhere('next_review_at', '<=', now());
})
->get();
return response()->json($cards);
}
Cards are "due" if next_review_at is null (never studied) or in the past.
2. User studies & rates each card (Vue 3 frontend)
<!-- StudySession.vue -->
<template>
<div class="card-container">
<div class="card" @click="flipped = !flipped">
<div v-if="!flipped">{{ currentCard.front }}</div>
<div v-else>{{ currentCard.back }}</div>
</div>
<div v-if="flipped" class="rating-buttons">
<button v-for="rating in [0, 1, 2, 3, 4, 5]"
:key="rating"
@click="submitRating(rating)">
{{ ratingLabel(rating) }}
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useStudyStore } from '@/stores/studyStore'
const store = useStudyStore()
const flipped = ref(false)
const currentCard = computed(() => store.currentCard)
async function submitRating(quality) {
flipped.value = false
await store.rateCard(currentCard.value.id, quality)
}
function ratingLabel(q) {
const labels = ['Blackout', 'Wrong', 'Wrong (familiar)', 'Hard', 'Good', 'Easy']
return labels[q]
}
</script>
3. Backend applies SM-2 and saves
// StudySessionController.php
public function rateCard(Request $request, $cardId)
{
$card = Card::findOrFail($cardId);
$quality = $request->input('quality'); // 0-5
// Run SM-2
if ($quality < 3) {
$card->repetitions = 0;
$card->interval = 1;
} else {
if ($card->repetitions === 0) {
$card->interval = 1;
} elseif ($card->repetitions === 1) {
$card->interval = 6;
} else {
$card->interval = round($card->interval * $card->ease_factor);
}
$card->repetitions += 1;
}
$newEF = $card->ease_factor + (0.1 - (5 - $quality) * (0.08 + (5 - $quality) * 0.02));
$card->ease_factor = max(1.3, $newEF);
$card->next_review_at = now()->addDays($card->interval);
$card->save();
return response()->json($card);
}
What I Learned Building This
1. SM-2 is deceptively simple. The algorithm itself is tiny β the complexity is in building the session UX around it (card flipping, ratings, progress tracking).
2. The ease factor is the secret sauce. Cards you consistently ace get pushed further and further apart automatically. Cards you keep failing stay frequent. You don't need to configure anything β it self-adjusts.
3. "Due cards" queries need an index. Once you have thousands of cards, querying by next_review_at gets slow. I added a database index on that column and it made a noticeable difference:
$table->index('next_review_at');
4. Failed cards should NOT reset interval to 0. A common mistake is setting interval = 0 on failure, which means the card shows up immediately in the same session forever. Setting it to 1 (review tomorrow) feels much better in practice.
What's Next
The SM-2 algorithm is from 1987 β it works, but there are newer alternatives. The FSRS algorithm (Free Spaced Repetition Scheduler) is a modern neural-network-based approach that reportedly outperforms SM-2. It's on my roadmap to implement next.
If you're curious about the full project β including AI card generation with Groq LLaMA, community decks, and Google OAuth β the repo is here: github.com/chelsynew72/flashcard-app and the live demo is at flashcard-app-five-gamma.vercel.app.
Have you built anything with spaced repetition? I'd love to hear how you approached it β drop a comment below!
Top comments (0)