DEV Community

Cover image for How I Implemented the SM-2 Spaced Repetition Algorithm in My Flashcard App
tem chelsy
tem chelsy

Posted on

How I Implemented the SM-2 Spaced Repetition Algorithm in My Flashcard App

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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)