# Enhancing Technical Learning with Spaced Repetition Systems URL: https://madhudadi.in/blog/posts/spaced-repetition-for-technical-learning-sm-2-algorithm Published: 2026-06-09 Tags: Architecture, FastAPI, Production, PostgreSQL Read time: 15 min Difficulty: advanced > How the SM-2 spaced repetition algorithm is implemented, how review schedules are computed from reading history, and why SRS beats bookmarks for long-term retention.# 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: ```python 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 reviewed - `next_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: ```python 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: ```python 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: ```python @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: ```tsx function SrsReviewCard({ review, onSubmit }: Props) { const [quality, setQuality] = useState(null); return (

Review Due

{review.post_title}

Previously reviewed {review.review_count} times. Current interval: {review.interval} days.

{quality === null ? (
{[ { value: 1, label: "Forgot" }, { value: 3, label: "Hard" }, { value: 4, label: "Good" }, { value: 5, label: "Easy" }, ].map((btn) => ( ))}
) : (

Review recorded. Next review scheduled.

)}
); } ``` --- ## 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 ```tsx function BrainClient({ stats }: { stats: BrainStats }) { return (
); } ``` 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.*