← all writing

JavaScripttoolssmall3 min

201 lines and a timing bug I left in

weaver is a one-evening RSVP speed reader — one word at a time, at whatever WPM you set. It works, and its core timing approach is wrong in a way that's worth explaining.

weaver is three files and 201 lines: a textarea, a WPM box, and a div that flashes one word at a time. No build step, no dependencies, no framework. Open index.html and it runs.

I wrote it because I wanted to try RSVP — rapid serial visual presentation, the thing where text is fed to you one word at a time in a fixed position so your eyes never move. There are polished apps for this. I wanted to know if I'd actually use it before installing one.

The whole idea

const millisecondsPerWord = 60000 / wpm;

intervalId = setInterval(() => {
    wordIndex++;
    if (wordIndex < words.length) {
        wordDisplay.textContent = words[wordIndex];
    } else {
        clearInterval(intervalId);
        wordDisplay.textContent = "Viewer Finished";
    }
}, millisecondsPerWord);

text.split(/\s+/), a setInterval, done. At 300 WPM that's a word every 200ms.

The nicest touch is the smallest one: when you hit start, the textarea gets a transparent-input class and fades out, so the text you pasted stops competing with the word you're reading. The input is still there, still focused, still holding your text — it just stops being visible. One CSS class, and the interface gets out of the way.

The bug I'm not fixing

setInterval is the wrong primitive here, and I knew it when I wrote it.

It doesn't guarantee your callback runs every N milliseconds. It guarantees it gets queued every N milliseconds. If the main thread is busy — a background tab throttling, a garbage collection pause, anything — the callbacks bunch up and the words come out unevenly. At 200ms intervals you won't consciously notice. At 600 WPM (100ms) you can feel it: the rhythm stutters, and rhythm is the entire mechanism RSVP depends on.

The correct version schedules against a monotonic clock — track the intended time for word n, compare against performance.now(), and use requestAnimationFrame or a self-correcting setTimeout to absorb the drift.

What the structure got wrong

The other flaw is more embarrassing and more instructive: startViewer and resumeViewer contain the same interval-creating block, copy-pasted.

function resumeViewer() {
    isPaused = false;
    const wpm = parseInt(wpmInput.value);
    const millisecondsPerWord = 60000 / wpm;
    intervalId = setInterval(() => { /* ...identical... */ }, millisecondsPerWord);
}

That duplication happened because I modelled the state as three booleans — isRunning, isPaused — instead of one state machine. Two booleans give four combinations, only three of which are legal, and there's nothing stopping the illegal one. controlWordViewer is an if-else chain reconstructing a state machine that was never written down.

The version with state = 'idle' | 'running' | 'paused' and one transition function would be shorter than what's there, and the tick logic would exist once.

I'm leaving both flaws in place. This is a tool I built in an evening for myself, it does what I needed, and rewriting it would be practising rather than building. But they're the two things I'd fix first, and they're both the same lesson in different clothes: model the states, then write the code — not the other way round.

Did I use it?

For a few weeks, then no. RSVP is genuinely fast and genuinely bad for anything you need to think about — you can't reread the previous clause, because there is no previous clause. It's good for skimming things you've already read.

What stuck wasn't the reader. It was noticing that the reason I couldn't retain what I skimmed had nothing to do with reading speed, which is a thread that leads directly to spaced repetition and everything I've built since.

← all writing
~/mohith.tech/blog 3 min read © 2026 Mohith Sarma K L K