A scratchpad runs code you haven't finished writing. That's the job. Which means it runs a lot of code like this:
let i = 0;
while (i < 10) {
console.log(i);
// forgot i++
}
Where that code runs decides whether you lose a second or lose your editor.
Option 1: run it in the same thread
The simplest scratchpad is eval (or new Function) in the page that hosts the editor. It's fast and it's one line. It's also a trap.
JavaScript runs one thing at a time per thread. While that while loop spins, nothing else on the thread runs: not your Stop button's click handler, not the editor's rendering, not the code that would show an error. Paste it into a browser console and that tab freezes until the browser offers to kill it.
There's no try/catch for "this never returns".
Option 2: a worker
A Web Worker (or a Node worker_thread) runs on its own thread, so the UI stays responsive, and the host can stop it:
const worker = new Worker(new URL("./run.js", import.meta.url), { type: "module" });
setTimeout(() => worker.terminate(), 2000);
Better. But a worker still lives in your process. It shares the process's environment, its working directory and its fate: if something in it brings the process down, the editor goes too.
Option 3: a separate process
Give each run its own OS process and the operating system becomes the boundary. Here's a minimal runner that works in Node and in Bun:
import { execFile } from "node:child_process";
function runFile(file, ms = 2000) {
return new Promise((resolve) => {
execFile(process.execPath, [file], { timeout: ms }, (error, stdout, stderr) => {
resolve({ stdout, stderr, timedOut: error?.killed ?? false });
});
});
}
// never-ends.js contains: while (true) {}
await runFile("./never-ends.js");
// { stdout: "", stderr: "", timedOut: true }
timeout makes Node send the child a signal when time's up. What that buys you:
- Kill always works. A process can be killed from outside no matter what it's doing. No cooperation needed.
-
Crashes stay contained. A segfault in a native addon, an out-of-memory, a
process.exit()in a library: the child dies, the host carries on. - Every run starts clean. No globals left over from the last run, no timers still firing from three edits ago.
-
Real process semantics.
process.env,process.cwd(),stdout, exit codes and signals behave like they do in production, because it is a process.
The cost is start-up time on every run. That's why the runtime you pick matters: one that starts fast and runs TypeScript without a separate compile step keeps the loop tight.
How JSLab does it
JSLab, a JavaScript & TypeScript scratchpad for macOS, uses option 3. In a Bun tab, each run is a separate Bun process, using the Bun that ships inside the app. In a browser tab, each run is a freshly reloaded page. Either way, a crash or an infinite loop never takes the editor down.
On top of the process boundary:
- ⇧⌘R (Stop) stops a run gracefully, and kills it after 500 ms if it doesn't respond. ⌥⌘R (Kill) kills it at once.
- If a run stops answering for 3 seconds, JSLab asks: Kill or Wait.
-
Loop protection stops a runaway
whileloop like the one above after 2,000 iterations, with an error naming its line, before you even reach for Stop.
The details of Stop, Kill and loop protection are in today's feature note.
JSLab launches Nov 15. How runs work: https://js-lab.app/docs/editor

Top comments (0)