Skip to content

Sami

Home

Projects

About

Blog

Home

Projects

About

Blog

Back to Blog

How I Built Speeedy: A Local-First RSVP Speed Reader from Scratch

Building a custom RSVP engine, 12-format document parser, procedural ambient audio, and reading analytics. All in the browser, no account required.

March 27, 2026 · 10 min read

Read this in Speeedy

Speed-read this article in your browser — no account needed

⚡

Speed reading tools have been around for decades, but the good ones always seemed to come with a catch. Either they required an account. Or they stored your documents on a server. Or they had one killer feature — speed, or privacy, or document format support — but nothing else. I wanted to build the version of this tool that I'd actually use: local-first, no account, no server, but with everything you'd want in a serious reading environment.

That's Speeedy. It uses Rapid Serial Visual Presentation (RSVP) — the technique where words flash one at a time in a fixed position — to eliminate the eye movements that slow conventional reading down. It went from idea to v1.0.0 in one clean release cycle.


Why RSVP?

When you read a line of text the conventional way, your eyes don't glide smoothly across it. They jump in short bursts called saccades, land, process a few words, then jump again. Those movements and the fixation pauses between them account for a significant chunk of total reading time.

RSVP removes that entirely. One word appears at the center of your screen. The next word replaces it. Your eyes don't move. You just read.

The tricky part is making it feel natural rather than like being pelted with words. That's where the engine design matters.


The RSVP Engine

The core of Speeedy is a custom playback engine in rsvp-engine.ts. I didn't want to rely on a simple setInterval to flash words at a fixed rate, because that ignores everything that makes reading feel human.

The engine is token-based. Every word goes through an ORP (Optimal Recognition Point) computation, which splits it into three segments: the letters before the pivot, the pivot letter itself, and the letters after. The pivot always lands at the same horizontal position on screen. So your eye already knows where to focus before the next word arrives — the brain just has to process, not locate.

Timing is where most of the work lives. The base WPM gets adjusted per-word based on several signals:

  • Character length: longer words get more time
  • Internal punctuation: a word with a dash or parenthesis gets a small pause after it
  • Sentence boundaries: a word that ends a sentence waits longer before the next one
  • Paragraph breaks: same, but bigger
  • Comma pauses: configurable
  • Speed ramp: optional gradual acceleration from a lower WPM up to the configured target

The result is that 400 WPM through a novel feels nothing like 400 WPM through a technical paper, because the engine is reading the text, not just its word count.

There's also live rescheduling. If you adjust speed with the arrow keys while a session is running, the engine cancels the pending timeout and reschedules the next word with the updated timing immediately. No jitter, no dropped words.


Document Parsing: Twelve Formats in the Browser

One of the things I was most determined to get right was format support. Speed reading is only useful if you can actually get your content into the reader. I wanted to handle everything without ever sending a file to a server.

The parsing pipeline in text-parser.ts handles:

  • PDF via pdfjs-dist — but not naively. PDFs don't have paragraphs or lines at the format level. They have glyphs with X/Y positions and font metrics. I reconstruct readable prose by grouping glyphs into lines using their vertical positions, then grouping lines into blocks using spacing gaps.
  • DOCX via mammoth — straightforward, but I handle the mammoth worker loading carefully to avoid blocking the main thread.
  • EPUB via jszip — EPUB files are ZIP archives containing an OPF manifest that lists chapters in spine order. I parse the OPF, walk the spine, extract each chapter's HTML, strip tags, and concatenate them.
  • ODT — also a ZIP, with content in content.xml.
  • RTF — control-word stripping.
  • HTML/HTM — tag stripping to plain text.
  • CSV — row/cell parsing with quote handling.
  • TXT, Markdown — direct.
  • Unknown text-like files — attempted fallback.

The parser returns a normalized document object with a word count, a title derived from the filename or embedded source, and cleaned text ready for the engine. Every format gets user-facing error messages for encrypted, invalid, or unsupported files rather than silent failures.


Local-First Storage: IndexedDB Without the Pain

Speeedy stores everything locally. No account. No sync endpoint. No server.

The storage layer uses idb (a thin, typed IndexedDB wrapper) with a database named speeedy-db. There are two stores: one for the user profile (settings, stats, streak, benchmark results), one for the document library (up to 20 documents with progress tracking).

A few things I'm glad I got right early:

SHA-256 content hashing for deduplication. If you upload the same PDF twice, the second upload refreshes the save timestamp instead of duplicating the text. The hash is computed in the browser using the Web Crypto API.

Schema versioning with multi-tab safety. IndexedDB upgrades can deadlock if multiple tabs have the database open. The storage service handles the blocked and versionchange events so upgrades don't corrupt data when the user has Speeedy open in more than one tab.

Default profile creation on first launch. When Speeedy boots for the first time, it creates a default profile with sensible settings, initializes font size based on screen size, and marks onboarding as unseen so the first-run flow triggers.

Documents store their resume position as a word index. When you reopen a document you've read partway through, the reader asks if you want to resume or start over, then rewinds a few words before the saved position so you have context.


Accessibility Stack

This was important to me. Reading tools often get built for the average case and ignore everyone else. Speeedy has:

Dyslexia mode — switches to OpenDyslexic styling with increased letter spacing and adjusted line height. Usable from the settings panel or from the first-run onboarding step.

Irlen overlays — a configurable color tint over the reading area. Irlen syndrome (also called scotopic sensitivity) causes some readers to experience visual stress or distortion with high-contrast black text on white. A colored overlay reduces that stress. The color is user-configurable.

Bionic reading — bold anchors on the first half of each word to give the eye a landing point. The "bionic focus position" (how much of the word gets bolded) is configurable.

ORP guide markers — small fixed markers on screen that show the reader where the pivot letter will always appear, so there's no visual searching between words.

Peripheral context — ghost words before and after the current word, with configurable count. These don't require eye movement to perceive, but they give the brain orienting information about what's coming and what just passed.

RTL support — Arabic and Hebrew words require different ORP alignment. The pivot compensation uses measured DOM offsets rather than CSS text-alignment, because the visual center of a right-to-left word is not the same as its logical center.


Web Audio: Ambient Noise and Click Sounds

The audio system was one of the most technically interesting parts of the project to build.

The ambient noise generator produces white, pink, or brown noise entirely in the Web Audio API — no audio files anywhere in the codebase. The noise is generated procedurally into pre-computed AudioBuffers, then looped with crossfade smoothing at the loop boundaries to prevent audible clicks when the buffer wraps. The crossfade is short — just long enough that the seam is invisible, even when the buffer has been playing for a long time.

Per-word click sounds are also procedurally generated. The pitch is configurable, and different events (sentence endings, commas) get different pitches so reading has a subtle rhythmic texture that helps with focus without being distracting.

Audio contexts are suspended automatically when the browser tab loses focus and resumed on visibilitychange, pageshow, and focus events. This matters because browsers aggressively suspend Web Audio contexts when pages aren't visible, and recovering from a suspended context without user interaction requires handling the resume carefully.


Stats, Benchmark, and Habit Tracking

Speeedy is designed around the idea that reading is a skill you improve over time, not just a utility for getting through documents.

Every reading session records: words read, duration, WPM, completion percentage, and source title. This feeds into:

  • Average WPM over recent sessions
  • Streak tracking — current streak and best streak, with a reset if you skip a day
  • Daily goal progress — configurable target and a progress bar showing today's word count
  • WPM trend chart — inline polyline chart rendered in SVG/CSS, no charting library
  • 14-day words bar chart — same approach

The benchmark system gives the reading speed numbers something to anchor against. You read a timed passage at your own pace, then answer 10 comprehension questions. The result — baseline WPM and comprehension percentage — gets stored in your profile and can be used to calibrate the reader's starting speed. It also shows up on the stats dashboard so you can see how your reading speed compares to your comfortable baseline.


Sharing Without a Backend

The share feature was a constraint-led design: no backend, no database, no accounts, but still shareable.

Profile share cards are rendered to PNG using html-to-image, which walks the DOM and generates a canvas snapshot. The PNG is downloadable.

Share URLs encode a payload (display name, WPM stats, streak, avatar) as URL-safe base64 appended to the hash. The share route decodes the hash client-side and renders the profile card. No server ever sees the data.


Testing

I have 139 unit tests passing across four test files:

  • RSVP engine (54 tests): playback lifecycle, ORP computation, timing calculations, seek behavior, edge cases for empty or single-word documents
  • Stats service (38 tests): streak logic, aggregate calculations, session recording and retention capping
  • Text parser (20 tests): format detection, ORP splitting, bionic transformation, clean-text normalization
  • Text utilities (27 tests): word counting, time formatting, number formatting, reading time estimation

Six Playwright E2E suites cover: the marketing landing page, the app intake flow, the reader playback and keyboard shortcuts, the benchmark test, settings persistence, and the full keyboard navigation model.

The CI pipeline on GitHub Actions runs biome lint → Vitest unit tests → Vite build on every push.


Tech Stack

  • Lit 3 + TypeScript: custom elements for every route and UI component, light DOM rendering
  • Vite 8: dev/build with manual chunk splitting for heavy parsers (PDF, DOCX, JSZip, html-to-image get their own chunks)
  • Tailwind CSS v4 + DaisyUI 5: utility styling with DaisyUI theme tokens
  • Motion: scroll-driven and hover animations on the marketing and learn pages
  • idb: typed IndexedDB wrapper
  • pdfjs-dist, mammoth, jszip: in-browser document parsers
  • html-to-image, qrcode: share card generation and donation QR codes
  • Vitest + Playwright: unit and E2E testing
  • Biome: linting and formatting
  • Cloudflare Pages: hosting

What I'd Do Differently

The Lit custom element model worked well for this project — the component boundaries are clear, the service layer is clean, and the build output is small. But Lit's reactivity model requires careful thought about when to use @property vs @state vs a signal-like pattern for cross-component state. I had a few bugs early on where profile updates in a child component didn't propagate correctly to sibling components. The solution (custom events on the document + a root-level listener in the app shell) works, but it's more boilerplate than I'd like.

The parsing layer could use a worker thread for large PDFs. Currently, parsing a 300-page PDF blocks the main thread for a few hundred milliseconds. It's not catastrophic — there's a loading state — but moving it to a Web Worker would make the intake screen feel more responsive.


The Result

Speeedy is live at speeedy.pages.dev. v1.0.0 shipped on 2026-03-27. The source is on GitHub under MIT.

It's the kind of tool I wanted to exist and couldn't find: a serious speed-reading environment that respects your privacy, works with the documents you already have, helps you track your progress over time, and gives you enough customization to actually make reading comfortable — without asking you to create an account first.

On This Page

Why RSVP?The RSVP EngineDocument Parsing: Twelve Formats in the BrowserLocal-First Storage: IndexedDB Without the PainAccessibility StackWeb Audio: Ambient Noise and Click SoundsStats, Benchmark, and Habit TrackingSharing Without a BackendTestingTech StackWhat I'd Do DifferentlyThe Result