Most "save this for later" tools I've used eventually want a server: an account system, a database for your notes, a sync service with its own outage history. I wanted something narrower — capture text or a whole page while browsing, turn it into a spaced-repetition flashcard, and have it show up on my other machine — without running any infrastructure at all.

MindStack is a Manifest V3 Chrome extension that does exactly that: capture, spaced-repetition scheduling, a full dashboard, and cross-device sync, built entirely on chrome.storage.sync and chrome.identity. No backend, no bundler, no npm install before you can load it unpacked. Here's what that constraint forces you to get right.

Decision 1: The scheduler is SM-2-shaped, not SM-2

Spaced repetition apps usually reach for a full SuperMemo SM-2 implementation — ease factors computed from response quality on a 0–5 scale, per-review interval history. MindStack's actual scheduler is a compressed version that captures the two properties that matter for a lightweight capture tool and drops the rest:

const scoreReview = async (score) => {
  const memory = state.memories.find((item) => item.id === activeReviewId);
  const interval = {
    forgot: 1,
    hard: Math.max(1, Math.round((memory.reviewCount || 1) * 1.5)),
    good: Math.max(2, Math.round((memory.reviewCount || 1) * (memory.ease || 2.5))),
    easy: Math.max(4, Math.round((memory.reviewCount || 1) * ((memory.ease || 2.5) + 1)))
  }[score];

  const updated = {
    ...memory,
    reviewCount: (memory.reviewCount || 0) + 1,
    successCount: (memory.successCount || 0) + (score === "forgot" ? 0 : 1),
    ease: Math.min(3.4, Math.max(1.3,
      (memory.ease || 2.5) + ({ forgot: -0.35, hard: -0.12, good: 0.05, easy: 0.16 }[score])
    )),
    nextReviewAt: addDays(interval),
  };

Enter fullscreen mode Exit fullscreen mode

Two properties, deliberately preserved from SM-2: intervals grow multiplicatively with review count (so a card you keep getting right gets reviewed exponentially less often, not linearly less often), and the ease factor is clamped to [1.3, 3.4] — the same floor SuperMemo uses, because below ~1.3 a card's interval stops growing at all and it becomes a permanent daily nuisance instead of graduating out of the queue.

What's missing on purpose: SM-2's interval formula is previous_interval × ease_factor, which requires storing the previous interval per card. MindStack derives the next interval from reviewCount instead — a proxy, not the real thing. For a browsing-habit tool where you're scheduling "did I actually retain this article" rather than cramming vocabulary for an exam, that approximation is close enough, and it means one fewer field to store, sync, and keep consistent across devices. Building the textbook version would have been the wrong amount of engineering for what this tool is actually for.

Decision 2: The account gate is a UI convention, not a backend contract

MindStack uses chrome.identity.getProfileUserInfo to read whichever Google account the browser is signed into — it never authenticates against MindStack's own servers, because there are none:

if (!isExtension() || !chrome.identity?.getProfileUserInfo) { /* preview-mode fallback */ }
chrome.identity.getProfileUserInfo({ accountStatus: "ANY" }, async (profile) => { ... });

Enter fullscreen mode Exit fullscreen mode

Saving is disabled until that check reports a connected profile:

const accountConnected = (data) => Boolean(data?.account?.connected && data?.account?.email);
const connectRequiredMessage = "Connect your Google account in MindStack before saving.";

Enter fullscreen mode Exit fullscreen mode

This is honest about what it actually verifies: which Chrome profile you're using, not an authenticated session with any MindStack backend, because chrome.storage.sync already keys data to that same Chrome profile automatically. The gate exists so a user never captures a dozen notes locally, only to discover afterward they were never going to sync anywhere. It's a UX guardrail against a foot-gun, not a security boundary — and I designed it as exactly that, rather than dressing it up as authentication it isn't.

Decision 3: One storage key, one deep-merge read

Every surface of the extension — the popup, the dashboard, the content-script resurfacing layer, the options page — reads and writes the exact same shape from the exact same key:

const STORE_KEY = "mindstack:data";

const readData = async () => {
  const stored = await storageArea().get(STORE_KEY);
  return {
    ...DEFAULT_DATA,
    ...(stored[STORE_KEY] || {}),
    settings: { ...DEFAULT_DATA.settings, ...((stored[STORE_KEY] || {}).settings || {}) },
    account: { ...DEFAULT_DATA.account, ...((stored[STORE_KEY] || {}).account || {}) },
  };
};

Enter fullscreen mode Exit fullscreen mode

chrome.storage.sync has a 100KB total quota and an 8KB per-item limit, which rules out one storage key per memory card at any real scale of usage. A single JSON blob avoids that ceiling entirely and gives every surface (background service worker, dashboard, content script) the same read path with the same default-filling behavior — nobody has to remember to backfill settings.dailyTarget on a fresh install; readData() does it unconditionally, every time, by spreading DEFAULT_DATA underneath whatever's actually stored. New settings fields added in later versions appear for existing users automatically, without a migration step, because the defaults are re-applied on every read rather than written once at install time.

The trade-off is real too: every write round-trips the entire memory library through chrome.storage.sync, so at genuinely large card counts this stops being free. For a personal knowledge-capture tool measured in hundreds of cards, not tens of thousands, that trade-off is the right one — it buys atomic-enough writes and zero schema-migration code, which matters far more at this scale than storage efficiency does.

Decision 4: Resurfacing lives in the content script, gated by both settings and a delay

The most Chrome-extension-specific problem here: how do you remind someone of a due memory while they're browsing, without becoming the extension that nags on every page load and gets uninstalled in a week?

const maybeShowResurface = () => {
  if (!chrome.storage) return;
  area.get(key, (stored) => {
    const data = stored[key];
    if (!data?.settings?.resurfaceEnabled) return;

    const due = (data.memories || [])
      .filter((memory) => !memory.archived && new Date(memory.nextReviewAt) <= new Date())
      .sort((a, b) => new Date(a.nextReviewAt) - new Date(b.nextReviewAt));

    if (!due.length) return;
    // ...build and append the toast card
  });
};

setTimeout(maybeShowResurface, 1800);

Enter fullscreen mode Exit fullscreen mode

Two small choices carry the whole feature: a per-user resurfaceEnabled setting checked before touching the DOM at all, and an 1800ms delay before the check even runs. The delay isn't decoration — it means the toast never competes with a page's own load-time layout shifts, and it means a quick tab-through (open, glance, close) never triggers it. The card shows the oldest due memory, not a random one, so the on-page nudge and the dashboard's review queue always agree on what's next — there's no separate "what to show in the toast" ranking to keep in sync with "what to show in the queue."

What I'd take away

  1. Approximate the algorithm you actually need, not the one with the famous name. A compressed SM-2 that captures "harder answers shrink the interval, easier answers grow it multiplicatively" gets you 90% of spaced repetition's value with a fraction of the state to store and sync.
  2. Don't let a UI convenience gate pretend to be a security boundary — but don't skip it either. chrome.identity.getProfileUserInfo proves nothing about a backend that doesn't exist; it exists purely to stop a user from capturing notes that were never going to sync.
  3. One storage key with defaults re-applied on every read beats a schema you have to migrate. It costs some write efficiency at scale; for a personal tool, that's the correct trade.

The full extension — manifest, background service worker, content script, dashboard — is at github.com/abhijatchaturvedi/MindStack. Load it unpacked via chrome://extensions, capture a page with Alt+Shift+W, and watch the review queue pick it up on its own schedule.