A 5-second file scan was freezing my phone UI: two worker-thread lanes and a hard timeout in Node.js
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 affili
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: it answers the phone's HTTP requests, and on a timer it also rebuilds a project index, runs a housekeeping pass and builds a summary "board" file. Those periodic jobs are almost entirely file reading: walking folders, parsing transcripts, writing JSON.
They were also the reason the phone sometimes sat on a spinner for 2 to 5 seconds. Node runs your JavaScript on one thread. While a synchronous scan walks a few thousand files, every request that arrives waits behind it, including "open this session", which the person on the phone is staring at.
This post is the small module that moved that work off the main thread, and the four decisions in it that mattered more than the move itself.
Decision 1: only file work leaves the main thread
The daemon also drives the desktop app's window. That code stays where it is. Worker threads are good at CPU and file work with no shared state; they are a poor place for anything that touches a UI or holds handles the main thread also uses.
So the module has an explicit allow-list of named tasks, and nothing else can run off-thread:
const TASKS = {
'index.build': (opts) => require('./projects').build(opts || {}),
'hygiene.cycle': () => require('./hygiene').cycle({}),
'board.build': async () => {
const r = await require('./board-build').build({});
return { written: r.written, file: r.file, reason: r.reason };
},
'bg.scan': ({ file }) => require('../mobile/sessions').backgroundFromFile(file),
};
Two details are deliberate:
- Tasks are looked up by name and the module
requires the real code inside the worker. The caller never sends a function across, which you cannot do anyway, and it keeps the worker's surface small enough to read in one screen. -
board.buildreturns three fields, not the board. The result is structured-cloned back to the main thread. The caller only logs the outcome, and the phone reads the file the task wrote, so copying a large object back would be pure cost.
Decision 2: two lanes, one job at a time each
The first idea was "one worker, run everything there". That moves the stall instead of removing it: a 5-second housekeeping pass now delays "list background jobs for this session" in the worker queue instead of on the main thread. The phone still waits.
So there are named lanes, each a worker plus a FIFO queue, each running one task at a time:
-
slowfor the periodic rebuilds nobody is watching; -
fastfor things a phone request is waiting on.
const lanes = new Map(); // name -> { worker, queue, current }
function run(laneName, task, arg) {
if (!TASKS[task]) return Promise.reject(new Error('no such off-thread task: ' + task));
let lane = lanes.get(laneName);
if (!lane) lanes.set(laneName, lane = { worker: null, queue: [], current: null });
return new Promise((resolve, reject) => {
lane.queue.push({ task, arg, resolve, reject });
next(lane);
});
}
One at a time per lane is a feature. Two index builds running at once would race on the same output file; a queue makes them sequential without any locking.
Decision 3: a hung task takes its worker with it
A task that never settles is the failure that would quietly break everything: the lane's queue would block forever, and every later caller would await a promise that never resolves.
Each job gets a timer (10 minutes by default, overridable by an environment variable). If it fires, the worker is terminated and the job rejects with a message that names the task:
job.timer = setTimeout(
() => drop(lane, `${job.task} took longer than ${Math.round(TIMEOUT_MS / 1000)} s`),
TIMEOUT_MS);
drop removes the worker's listeners, terminates it, rejects the current job and starts the next one, which spawns a fresh worker. The same path handles a worker that throws at top level or exits with a code.
The comment next to that timer records a choice I almost got wrong: a timed-out task is not retried inline on the main thread. Whatever hung in the worker would hang the main thread just the same, and then the phone is stuck too. Failing the one job is the better outcome.
Decision 4: the worker must not keep the process alive
A daemon that refuses to exit because an idle worker is still referenced is an annoying bug to find later. The worker is unref()'d when created, ref()'d only while a job is in flight, and unref()'d again when its lane has nothing current and nothing queued. Timers are unref'd as well.
The escape hatch and the tests
Setting RELAYMOTE_OFF_THREAD=0 runs every task inline, through the same run() call. That is useful for debugging (stack traces stay in one thread) and for proving that a behaviour change came from the threading and not from the task.
A test-only set of tasks is registered when an environment flag is on: one that reports its thread id, one that busy-waits, one that never resolves, one that throws, and one that calls process.exit(7). Those cover the paths that matter: the work really ran elsewhere, a busy slow lane does not delay fast, a hang rejects after the timeout, a thrown error comes back with its name, and a dead worker is replaced.
The checklist, if you have the same problem
- Measure first: find which synchronous jobs stall the loop, and whether they are file/CPU work.
- Move only those, by name, through an allow-list.
- Separate what a user is waiting on from background rebuilds, with a lane each.
- Give every job a deadline that kills the worker, and do not fall back to running it inline.
- Return small results; let large outputs go through the file they already write.
- Keep an off switch that runs the same code inline.
Relaymote is on GitHub under the MIT licence. Unofficial; not affiliated with or endorsed by Anthropic.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.