DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Small JSON files read on every tick were stalling Node.js on a saturated disk: a parse-once cache that looks at mtime once a second

Disclosure: I'm the developer of Relaymote, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.

Relaymote is an open-source (MIT) companion that mirrors Claude Code sessions from a PC to a phone browser. The PC side is one Node.js daemon with several watchers that wake on a timer. Each of them keeps a little state in a JSON file, and each reads it on every wake.

On a healthy disk that costs nothing. On a PC whose disk was saturated by something else, one of those reads took 1.8 seconds, and because fs.readFileSync blocks the event loop, every phone request waited behind it. The files were a few kilobytes. The problem was never their size; it was how often they were touched and what a touch costs when the disk is slow.

This post is the 30-line module that fixed it, and the three decisions in it.

The shape of the problem

A watcher that runs every few seconds typically does this:

function loadState() {
  try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return {}; }
}
Enter fullscreen mode Exit fullscreen mode

Three things are wrong with it once the disk is slow:

  • it reads and parses the file on every tick, though the file changes rarely;
  • it is synchronous, so a slow read stalls everything else in the process;
  • the catch turns a half-written file into {}, so a partial write can look like "state was reset".

Decision 1: parse once, look at the file's time at most once a second

const LOOK_MS = 1000;
const cache = new Map();   // file -> { mtime, looked, text }

function readJsonCached(file, dflt) {
  const now = Date.now();
  let c = cache.get(file);
  if (!c || now - c.looked >= LOOK_MS) {
    let m = -1;
    try { m = fs.statSync(file).mtimeMs; } catch {}
    if (!c || c.mtime !== m) {
      let text = null;
      if (m >= 0) { try { text = fs.readFileSync(file, 'utf8'); JSON.parse(text); } catch { text = null; } }
      c = { mtime: m, looked: now, text };
      cache.set(file, c);
      if (cache.size > 200) cache.delete(cache.keys().next().value);
    } else c.looked = now;
  }
  if (c.text === null) return dflt;
  try { return JSON.parse(c.text); } catch { return dflt; }
}
Enter fullscreen mode Exit fullscreen mode

Two clocks are in play. looked is when we last asked the filesystem; mtime is what the filesystem said. Inside the one-second window the function does no I/O at all. After it, it does a stat, which is far cheaper than a read, and only re-reads when the modification time moved.

The cost of this is that a file changed by another process can be up to a second stale. For state that a watcher polls every few seconds, that is invisible; for anything that must be fresh, do not use this module.

Decision 2: callers get their own copy

The cache stores the text, not the parsed object, and parses on each call. That looks wasteful until you consider the alternative: hand out one shared object and the first caller that does state.seen.push(x) has silently changed everyone else's view, and nothing was written to disk. Parsing a few kilobytes is microseconds; a shared mutable object is a bug that shows up weeks later. After this change, mutating what you were given changes nothing until you write it back.

Decision 3: the file is validated before it is cached

JSON.parse(text) runs once on load purely as a check. If it throws, the cached text is null and callers get the default. A torn write therefore never gets cached as good data, and because the mtime is recorded, the next write fixes it on the next look.

The writer's side has one rule: after this process writes a file, call forget(file) so the next read comes from disk instead of waiting out the one-second window.

function forget(file) { cache.delete(file); }
Enter fullscreen mode Exit fullscreen mode

The map is also capped at 200 entries, dropping the oldest, so a long-running daemon that sees many paths cannot grow it without bound.

What I would check before copying it

  • It fits state files that are read often and written rarely. For a file written every tick, the cache never hits.
  • It is per-process. Two processes writing the same file still need their own write discipline; this only reduces reads.
  • The one-second window is a constant, not a measured number. I chose it because the watchers tick at multi-second intervals; pick yours from your own tick rate.

The module is lib/json-cache.js in the repository, and the two watchers that read through it are chipwatch and account-sync.

Top comments (0)