DEV Community

Cover image for FSRS Plugin for Obsidian: Rust/WASM Architecture and Performance
Evgene
Evgene

Posted on

FSRS Plugin for Obsidian: Rust/WASM Architecture and Performance

FSRS Plugin for Obsidian: Rust/WASM Architecture and Performance

A spaced repetition tool for Obsidian notes must use a modern algorithm and work locally with notes as-is (without rewriting them into flashcards).

Existing Obsidian plugins stop at the SM-2 algorithm circa 1987.

Alternative solutions exist "somewhere else" — outside free software, outside Markdown-first architecture — tied to the cloud or a proprietary format.

I wrote my own because I couldn't find a suitable one.

FSRS, a computational core in Rust compiled to WebAssembly.

This article covers: WebAssembly architecture, a custom parser, a lexer, and performance benchmarks. Every query runs in hundredths of a second. Blazingly fast 🦀

This is a technical article. For a step-by-step user guide, see the overview article.

plugin demo: card table and popup preview


Why a Fourth Spaced Repetition Plugin?

At the time of writing, three popular solutions exist in Obsidian: obsidian-spaced-repetition, obsidian-recall, and obsidian-review. All use SM-2 — an algorithm nearly 40 years old. It works, but requires roughly 30% more reviews than FSRS for the same retention level.

The main drawbacks of SM-2:

  • Same interval regardless of material difficulty
  • Doesn't handle breaks — resets progress after a gap
  • No concept of retrievability — the probability of recalling a card right now

FSRS is a step forward. But it wasn't in Obsidian. Until now.

sm-2-vs-fsrs


How FSRS Works in a Nutshell

FSRS operates on a DSR model with three parameters:

  1. Difficulty — how hard the material is. Range: 0–10
  2. Stability — memory strength in days
  3. Retrievability — probability of recalling the card right now

After each answer (Again / Hard / Good / Easy), the algorithm recalculates difficulty and stability. Retrievability changes continuously.

The algorithm uses 21 parameters, tuned by machine learning on millions of real reviews.

DSR-schema


Architecture: Why Rust and WebAssembly

An Obsidian plugin is JavaScript. But FSRS requires precise floating-point calculations on every review.

I chose Rust for three reasons:

  1. Performance — WASM runs orders of magnitude faster than JS on numerical computations
  2. Ecosystem — the rs-fsrs crate from the open-spaced-repetition community, providing the reference FSRS implementation
  3. Type safety — in Rust you can't accidentally mix up difficulty and stability

Separation of concerns:

TypeScript                  Rust/WASM
─────────                   ─────────
• Obsidian API              • FSRS computations
• UI / rendering            • SQL-like syntax parsing
• File system               • YAML/JSON parsing
• Plugin lifecycle          • Filtering and sorting
• Buttons, modals           • Card cache
Enter fullscreen mode Exit fullscreen mode

fsrs-plugin-schema

TypeScript is a thin wrapper over the Obsidian API. All logic lives in WASM.


Performance: The WASM ⇆ JS Boundary

Minimizing Cross-Boundary Copying

  1. Cache inside WASM — filtering and sorting happen right there in Rust. Only the result (20–200 rows) crosses the boundary, not all 10,000 cards.
  2. Incremental updates — answering a card recalculates only one record.

metadataCache: Don't Read Files

The biggest speed gain comes from not reading files. Obsidian stores parsed frontmatter in metadataCache — an in-memory cache that updates on every note change.

The plugin checks for FSRS fields via metadataCache.getFileCache() — instant, in-memory access, no I/O. Out of 105,607 files, only those whose frontmatter already contains reviews are actually read.

For reference: the plugin's own parser processes 105k files in 16 seconds, filters out 100k, and processes 5k.

Obsidian spends ~20 minutes indexing 105k files,
and ~120 seconds on 5,000 freshly added cards.
So the fair comparison is 16 seconds for the plugin vs. 120 for Obsidian.
But Obsidian does it anyway — so it's more efficient to use the ready-made cache.

Numbers

FSRS calculation for all cards runs once during the initial
vault scan. After that, the cache lives in WASM — all subsequent
operations (table load, heatmap, single card update) work with
pre-computed data and don't depend on vault size.

Operation Large vault (105k files, ~5,000 cards) Small vault (710 files, 104 cards)
Initial scan (FSRS for all cards) 3.2 s 0.04 s
Table load (after cache) 0.07 s 0.04 s
Heatmap 0.02 s 0.01 s
Single card update < 0.01 s < 0.01 s

The difference between 5,000 and 100 cards after caching — 0.03 s. Large vault logs, Small vault logs with plugins

Every plugin action, after the initial calculation, runs in hundredths of a second.

The Bottleneck

3.2 seconds for initial load — that's the FSRS calculation for each of 5,000 cards. Runs only once.

States could be persisted to disk cache, but:

  • Syncing complexity between devices (on-disk cache can go stale)
  • 5,000 cards / 3.2 seconds — acceptable for real-world use
  • After the first launch, subsequent plugin loads are instant — the cache is already in WASM

What's Wrong (About Trade-offs)

LIMIT in the current implementation doesn't short-circuit processing — to guarantee the first N rows by retrievability, all cards must still be evaluated.

A deliberate trade-off: a real user's vault rarely exceeds 5,000–10,000 entries; a full scan + sort takes 0.005–0.010 s.


WASM Cache Instead of Local State

The entire cache (HashMap<filePath, CachedCard>) lives inside WASM as a global variable. The plugin stores no state in TypeScript at all.

Why:

  • Single source of truth — no desync between JS state and WASM computations
  • Fast queries — filtering/sorting happen right where the data is
  • Incremental updates — targeted commands: "update this card" / "delete this one"

Where data lives. Review progress is stored directly in a note's YAML frontmatter:

---
reviews:
  - date: "2026-05-03T12:00:00Z"
    rating: 2
  - date: "2026-05-04T08:30:00Z"
    rating: 3
---
Enter fullscreen mode Exit fullscreen mode

due, stability, difficulty, and state are not stored — the WASM core computes them on the fly from the review history.


SQL-like Language for Tables

The plugin's headline feature — selecting cards for review via an fsrs-table block.
You write a SQL-like query in a markdown note and get a live table
that auto-updates with every review.

Under the hood: a custom parser built from scratch — lexer → parser → AST → evaluator
in Rust/WASM. Supports SELECT, WHERE, ORDER BY, LIMIT,
and the date_format() function.

Full breakdown in a dedicated article:
SQL-like Queries in FSRS Plugin.


No Mocks as a Consequence of Architecture

Mocks aren't needed — not because they're "forbidden," but because there's nothing to mock.

TypeScript in the plugin is a thin wrapper over the Obsidian API. All logic is in Rust/WASM. Mocking Obsidian would mean checking whether the plugin closes a <div>, or whether it called vault.read() — trivial glue not worth testing. What's worth testing: the integration between your own TypeScript and your own WASM.

So tests are split into two levels:

  • Rust tests (184) — pure functions, isolated from the environment
  • TypeScript tests (86) — unit tests for pure functions and TS → WASM integration tests

Integration tests start and end with your own code: a raw string, a parameter, or a call on the input (TS) → parsing (WASM) → WASM cache query (WASM) → result (TS).

tests-terminal


CI/CD: Build, Test, Release at the Push of a Button

A GitLab CI pipeline with seven stages:

  1. checkcargo fmt, cargo clippy, cargo test
  2. build-wasmwasm-pack build
  3. encode-wasm — embedding WASM as base64 for the bundle
  4. test — TypeScript tests (vitest)
  5. linttsc --noEmit + ESLint
  6. build — final main.js build
  7. release — automatic GitHub release

ci-green


Current Status

The plugin is ready to use.

Tested on Ubuntu, Windows, and Android.

Available in the Obsidian community plugin catalog.

What's already done:

  • CI/CD that builds and publishes releases automatically
  • Transparent storage — all data in YAML frontmatter of your .md files
  • TS → WASM integration tests (raw SQL, no mocks)
  • Heatmap
  • Russian, English, and Chinese localization
  • SQL-like queries for table-based card selection

Planned:

  • Gather feedback
  • Polish the mobile interface

How to Install

Available in the Obsidian community plugin catalog.

  1. Settings → Community plugins → Browse
  2. Find FSRSInstall
  3. Enable the plugin in Settings → Community plugins

Stack

  • TypeScript — Obsidian API, UI
  • Rust — computational core (WASM)
  • esbuild — JS bundle build
  • wasm-pack — WASM build
  • Vitest — TypeScript tests
  • GitLab CI/CD — pipeline

Links

Evgene Kopylov, 2026

Top comments (0)