I4 · Part 2 · How it runs
Where a reader's state lives
Thirty-six localStorage keys, two sessionStorage keys, a query string and a URL hash. There are no accounts, no cookies and no database.
07On this page
Everything is in the browser
This site keeps nothing about its readers on a server. No file under app/ uses
"use server". Nothing reads or writes a cookie. The three functions under
/api keep no data between requests, apart from a rate-limit counter in memory. So
everything the site remembers about you has to live in one of four places your browser already
has.
get, a set and, where a component
needs to follow changes, a subscribe. Components never touch
localStorage directly while rendering.
The core keys: lib/storage.ts
KEYS has ten entries, all prefixed jsnotes:, from the site's first name.
Every read and write goes through store. It JSON-encodes values, wraps each call in
try, returns the fallback on the server or when parsing fails, and makes
set() return false when the browser refuses a write, for example because
storage is full or disabled.
| Key | Holds |
|---|---|
jsnotes:progress | { chapters, exercises }: a mark per chapter read and a flag per exercise solved |
jsnotes:activity | A count per local calendar day, "2026-09-25": 3 |
jsnotes:code:{id}, jsnotes:code:{id}:{lang} | Unsaved editor text per exercise; JavaScript has no suffix |
jsnotes:level | The last level you read, so topic links go straight to it (a topic with chapter checks, such as DSA, keeps its level in its placement instead) |
jsnotes:theme, jsnotes:font | One of nine themes and eight fonts, read before paint by the theme-init script |
jsnotes:narration | Voice, rate and pitch |
jsnotes:sound-enabled | Whether solving an exercise plays a sound |
The other twenty-six
As features were added, each kept its own keys next to its own code. Newer ones use a
groundwork: prefix. Most still go through store. The whiteboard has its
own read and write in lib/whiteboard/storage.ts, because a
board with pasted images can fill the quota, and a failed save has to be reported to the reader
rather than silently ignored.
| Area | Keys |
|---|---|
| Reader | jsnotes:zoom, jsnotes:reading-budget, jsnotes:last-seen-level, groundwork:resume (the last chapter you opened, so the site menu can offer to continue there) |
| Editor | jsnotes:editor-settings, jsnotes:editor-height, jsnotes:playground-live, jsnotes:lang:{exerciseId} |
| Playground | groundwork:playground:project (every open file), groundwork:playground:runs (the last 15 runs), groundwork:playground:stdin |
| Whiteboard | groundwork:boards (the index), groundwork:board:{id} (one per board), groundwork:boards:last, groundwork:boards:prefs |
| Tour | groundwork:tour ({ v: 1, seen: true, at }, written when the first-visit tour is skipped or finished, so it never offers itself again; a missing or malformed value counts as not seen) |
| DSA chapter code | groundwork:dsa:lang (versioned: { v: 1, language }, one of JavaScript, Python, Java or C++, with its own store, snapshot cache and cross-tab storage listener; read only by a chapter that has marked code blocks) |
| Chapter check | groundwork:quiz (versioned: { v: 1, chapters }, one record per chapter with its attempts, best score, last attempt time, pass time, whether it was marked read without the check, the ids missed in the latest attempt, and whether the end-of-chapter popup was dismissed; it has its own store, snapshot cache and cross-tab storage listener, and a chapter's read mark stays in jsnotes:progress) |
| DSA placement | groundwork:dsa:placement (versioned: { v: 1, record }, one record with how the reader was placed, their level, a score for each stage, the chapters tested out and the chapters allotted, when it was taken and how many seconds it took; absent means not placed yet, and a stale version or junk reads as absent; it has its own store, snapshot cache and cross-tab storage listener, an in-memory copy when a write fails, and it never writes jsnotes:progress or jsnotes:level; /dsa/placement saves it when a placement ends, the doors save a start-from-the-beginning or picked-level record, a run in progress is kept in the sibling key groundwork:dsa:placement-draft, which a finished run or Reset removes, and Reset on the plan screen clears only these two keys) |
| Mock interview | groundwork:mock:config, groundwork:mock:current (a loop in progress), groundwork:mock:history (last 50), groundwork:mock:retry (up to 60 questions to try again) |
sessionStorage holds two more, groundwork:nav:here and
groundwork:nav:from. NavTrail in the root layout records every pathname
and title, so the playground's back button can say where you came from. That only makes sense
for the tab you are in. Three other strings that look like keys are not keys:
groundwork:problems-url is an event name, groundwork-board tags
clipboard and export JSON, and groundwork-preview is a postMessage
type.
The progress model
A chapter mark is { at, reviews }. The literal true is still accepted
from older saves and treated as { at: 0, reviews: 0 }. Review is spaced repetition
with fixed gaps: a chapter is due 3, 7, 21, 60 and then 180 days after the last time it was read
or reviewed. After the fifth review, dueAt() returns null and the
chapter is never due again. An exercise is simply true once solved.
The first time a chapter is marked or an exercise solved, a call to
recordActivity() adds one to today's count. lib/gamification.ts derives
everything else and stores none of it. The streak is the run of consecutive days with activity.
XP is 10 per exercise, 5 per chapter and 3 per review. The level is
floor(sqrt(xp / 25)) + 1. Keeping only the raw facts means a change to the formula
applies to everyone's history at once, with no migration.
Whether a tick marks a chapter read straight away is a topic setting, Topic.completion,
which is "read" unless set. lib/completion.ts has no runtime content imports and
answers it for every place that ticks a chapter and every place that picks the next one.
tickHref returns null for a "read" topic, and for a chapter that is
already read in any topic, so the usual button toggles the mark. For an unread chapter in a
"quiz" topic it returns a link to the chapter's #check, so a read chapter can
always be unmarked.
nextChapter is what Continue, Up next, the path's next step and the reading budget ask,
and it returns the first unread chapter. A server page passes completion down as a
string, because a function cannot cross into a client component. So far only the DSA topic is
"quiz", and the link lands on that chapter's check.
How components follow it
progress and activity share a single listener set. Every write calls
emitProgressChange(), and every component that used useProgressValue
re-reads its snapshot. The reading plan, the review badge and the streak all update the moment
you mark a chapter, and nothing polls. The mock store and run history keep their own listener
sets and cached snapshots. The rendering chapter
explains why snapshots must be primitives or cached objects.
What this does not do is follow other tabs. Nothing listens for the browser's
storage event, so a chapter marked in one tab shows up in another only after it
navigates or reloads. Adding that would be a single addEventListener in
progress.subscribe.
State in the URL
The /problems filters are the search box, topic, levels, status, sort and view. They live in
the query string, so a filtered list can be bookmarked or shared. ProblemsView reads
window.location.search with useSyncExternalStore. It subscribes to
popstate and to a custom groundwork:problems-url event, and its server
snapshot is the empty string. Changes are written with history.replaceState, leaving
out any value that equals its default, and then the event is fired. Typing in the search box
therefore does not add a history entry per keystroke.
It avoids useSearchParams() on purpose. On a prerendered page that hook renders
everything below the nearest Suspense boundary in the browser only. That is why
/practice ships 27 KB of HTML. With the external-store approach,
/problems prerenders the full unfiltered list, 276 KB of HTML, and applies your
filters straight after hydration. /practice?id=, the bare /path
and the old /level?topic= links do use
useSearchParams inside Suspense. Their HTML has nothing worth
prerendering anyway — the bare /path only ever reads a topic and a level from the
query string to redirect on. The old /soon?topic= links
never reach a client at all: next.config.ts redirects them to the topic's own cover or
level page, both prerendered, or to the home page. A written topic's old /path?topic=&level=
link is redirected by the same file straight to /path/<topic>/<level>, a
prerendered page that reads neither useSearchParams nor any other request input.
Share links live in the hash, which browsers never send to a server. lib/shareLink.ts
packs the playground's files as [name, lang, code] rows. It compresses them with
CompressionStream("deflate-raw"), encodes them as base64url and appends the result
as #share=. On the way in it accepts at most 20 files, 60-character names and 200,000
characters of code each. The whiteboard's #board= works the same way. It leaves out
pasted images, and on reading it keeps at most 2,000 elements. Both open what they receive as
new tabs or a new board next to your own work, never replacing it, and both clear the hash
afterwards. HashRedirect handles a third kind of hash: an old
/notes#closures link is forwarded to /notes/closures.
Why nothing is on a server
Because nothing is stored about anyone, there is nothing to sign in to, nothing to leak and no
database to run. That is also why every page can be a static file: no page differs by reader
until the browser has it. The cost is just as concrete. Clearing site data erases months of
progress. A second device starts from zero. And a full localStorage quota can only
be reported, not solved.
Progress is the state worth syncing, and all of it goes through progress and
activity in lib/storage.ts. An account-backed version would replace
those two objects' reads and writes and keep the same subscribe(). No component
would change. The other thirty-one keys are preferences and drafts that can stay per-device.
Finished reading?
0 of 26 chapters read in this series