Spaced Repetition for Technical Learning
Bookmarking a post means "I'll remember to come back to this." A spaced repetition system means "the system will remind me at the optimal time, based on how well I retained the information."
This blog implements the SM-2 algorithm — the same one used by Anki and SuperMemo — adapted for technical reading rather than flashcard review.
The SM-2 Algorithm
SM-2 schedules reviews based on four parameters per card:
class SrsCard(Base):
__tablename__ = "srs_cards"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
post_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("posts.id"))
ease_factor: Mapped[float] = mapped_column(default=2.5)
interval: Mapped[int] = mapped_column(default=0) # days
review_count: Mapped[int] = mapped_column(default=0)
next_review: Mapped[datetime] = mapped_column(default=func.now())
last_review: Mapped[datetime | None]
created_at: Mapped[datetime] = mapped_column(default=func.now())ease_factor— how easy the material was (starts at 2.5, ranges from 1.3 to 5.0)interval— days until the next review (grows with each successful review)review_count— number of times reviewednext_review— the date when the user should review this post again
The SM-2 Review Logic
When a user marks a post as reviewed, the card is updated:
SMALL_INTERVAL = 1 # 1 day
MEDIUM_INTERVAL = 6 # 6 days
EASE_MIN = 1.3
EASE_MAX = 5.0
def sm2_review(card: SrsCard, quality: int):
"""quality: 0 (forgot) to 5 (perfect recall)"""
if quality < 3:
card.review_count = 0
card.interval = SMALL_INTERVAL
else:
if card.review_count == 0:
card.interval = SMALL_INTERVAL
elif card.review_count == 1:
card.interval = MEDIUM_INTERVAL
else:
card.interval = round(card.interval * card.ease_factor)
card.review_count += 1
ease_delta = 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)
card.ease_factor = max(EASE_MIN, min(EASE_MAX, card.ease_factor + ease_delta))
card.next_review = date.today() + timedelta(days=card.interval)
card.last_review = datetime.utcnow()The quality parameter maps to a simplified UI:
| User Action | Quality | Effect |
|---|---|---|
| Forgot (couldn't recall the topic) | 0-1 | Reset to 1 day interval, ease drops |
| Hard (vaguely remembered) | 2-3 | Interval stays short, ease drops slightly |
| Good (remembered most) | 3-4 | Interval grows normally |
| Easy (perfect recall) | 4-5 | Interval grows faster, ease increases |
Automatic Card Creation from Reading Progress
Cards are created automatically when a user completes a post:
async def on_post_completed(user_id: str, post_id: str, db):
existing = await db.execute(
select(SrsCard).where(
SrsCard.user_id == user_id,
SrsCard.post_id == post_id,
)
)
if existing.scalar_one_or_none():
return # Card already exists
card = SrsCard(
user_id=user_id,
post_id=post_id,
next_review=datetime.utcnow() + timedelta(days=1),
)
db.add(card)The card's first review is scheduled for 1 day after completion — the "minimum useful interval" before which the content is still fresh in memory.
The Review Queue
The frontend fetches the user's due reviews:
@router.get("/srs/reviews")
async def get_due_reviews(user: User = Depends(get_current_user)):
cards = await db.execute(
select(SrsCard, Post.title, Post.slug).join(
Post, SrsCard.post_id == Post.id
).where(
SrsCard.user_id == user.id,
SrsCard.next_review <= func.now(),
).order_by(SrsCard.next_review.asc())
)
return [
{
"id": str(card.id),
"post_title": title,
"post_slug": slug,
"ease_factor": card.ease_factor,
"interval": card.interval,
"review_count": card.review_count,
}
for card, title, slug in cards.scalars()
]The frontend renders a review card:
function SrsReviewCard({ review, onSubmit }: Props) {
const [quality, setQuality] = useState<number | null>(null);
return (
<div className="card p-6">
<p className="text-[10px] font-bold uppercase tracking-widest text-amber-500 mb-2">
Review Due
</p>
<h3 className="font-display font-bold text-lg mb-4">
{review.post_title}
</h3>
<p className="text-sm text-white/60 mb-6">
Previously reviewed {review.review_count} times.
Current interval: {review.interval} days.
</p>
{quality === null ? (
<div className="flex gap-2">
{[
{ value: 1, label: "Forgot" },
{ value: 3, label: "Hard" },
{ value: 4, label: "Good" },
{ value: 5, label: "Easy" },
].map((btn) => (
<button
key={btn.value}
onClick={() => setQuality(btn.value)}
className="px-4 py-2 rounded-xl text-sm font-bold transition-all
border border-white/[0.08] hover:border-amber-500/30"
>
{btn.label}
</button>
))}
</div>
) : (
<p className="text-sm text-emerald-400">
Review recorded. Next review scheduled.
</p>
)}
</div>
);
}The Brain Page
The SRS system is surfaced through the "Brain" page at /milestones/brain. It shows:
- Due reviews — posts that need review today
- Upcoming reviews — posts scheduled for the next 7 days
- Mastered topics — posts with >5 successful reviews and ease factor >3.0
- Streak — consecutive days with at least one review completed
function BrainClient({ stats }: { stats: BrainStats }) {
return (
<div className="space-y-8">
<DueReviews cards={stats.due} />
<UpcomingReviews cards={stats.upcoming} />
<MasteredTopics cards={stats.mastered} />
<ReviewHeatmap history={stats.history} />
</div>
);
}The heatmap shows review activity over the past 90 days, similar to GitHub's contribution graph. Each cell represents a day, colored by the number of reviews completed.
Why SRS Beats Bookmarks
A bookmark says "I'll come back to this." An SRS card says "the system will bring this back to you at the optimal time." The difference is:
- Bookmarks decay — average browser bookmark list has 70% unread items. Users forget they exist.
- SRS schedules adapt — difficult material comes back sooner, easy material comes back later. The spacing optimizes for the forgetting curve.
The forgetting curve says: within 24 hours of reading, you forget ~60% of the content. A review at 24 hours resets the curve to ~90% retention. Each subsequent review at the right interval compounds retention toward near-permanent memory.
Without SRS, you re-read a post when you happen to remember it exists. With SRS, the post reappears when your retention is about to drop below 80%. That's the difference between passive browsing and active learning.
What's Next
The next post covers the premium paywall — how Razorpay integration works, premium gates, early access walls, subscription management, and the webhook handling that makes it all work without a third-party CMS.
Built with FastAPI, PostgreSQL, and the SM-2 algorithm — no third-party flashcard service.
