Engineering 21 min read

Keeping a Note Editor at 60fps with Web Workers

MMNMNOTE
web-workersperformanceoff-main-threadbrowser60fpsmarkdownlocal-first

A browser note editor has about 16 milliseconds to draw each frame. Parsing Markdown, building a full-text index, and searching a large vault can each blow past that in one long task — so move them into a Web Worker, off the main thread, and keep the interface at 60fps.

The hard part is that offloading is not the whole answer. A worker runs your parse or search on a background thread, but every message between the two threads has a cost, and the default cost is a full copy of the data. The Chrome team's RAIL model puts the per-frame budget at roughly 16 milliseconds, of which about 10 are yours after the browser paints. 1 Cross that line and frames drop. This post walks the frame budget, the three note-editor workloads that break it, the move to a worker, and the postMessage transfer-vs-copy trap that decides whether you actually won. It ends with a reproducible benchmark and an honest caveat about what that benchmark does and does not prove.

What eats a 60fps frame budget

At 60fps the browser has roughly 16 milliseconds per frame, and after it spends about 6 on layout and paint, an app has only about 10 milliseconds of its own to work in. Any single task over 50 milliseconds is a long task — long enough to drop frames and stutter.

The RAIL model states it directly: "the maximum budget for each frame is 16 ms (1000 ms / 60 frames per second ≈ 16 ms), but browsers need about 6 ms to render each frame, hence the guideline of 10 ms per frame." 1 The same document is blunt about what the reader feels: users "perceive animations as smooth so long as 60 new frames are rendered every second." 1 Miss the cadence and the cursor lags, the scroll judders, the keystroke arrives late.

The 50-millisecond line comes from a separate definition. web.dev's guidance on long tasks says plainly that "any task that takes longer than 50 milliseconds is a long task," and warns that "the user interface will feel unresponsive, and possibly even broken if the main thread is blocked for very long periods." 2 In a note editor, three workloads routinely cross that line on a large vault: parsing Markdown to render a preview, building a full-text index across every note, and running a query against that index while the user is still typing. Each is CPU-bound, each grows with vault size, and each runs, by default, on the one thread that also has to draw the next frame.

Consider each in turn. A Markdown parse is fast for a single note and slow for a bulk operation — reindexing a folder, importing an archive, or re-rendering after a global find-and-replace tokenizes every character of every file. An index build walks the entire corpus once to construct the structure a search will later query; on a vault of thousands of notes this is exactly the kind of multi-hundred-millisecond job the long-task definition warns about. And search, run naively on every keystroke, multiplies a moderate cost by the rate at which a fast typist emits characters. Any one of the three, left on the main thread, turns a smooth editor into a stuttering one at scale.

The naive fix that only half works

The reflex fix is to keep the work on the main thread but slice it up — parse a few notes per frame, yield, repeat. Chunking with requestIdleCallback smooths small jobs, but it does not add compute; a genuinely large index build still competes with rendering for the same thread, and interaction still stalls under load.

Time-slicing is a real technique, and for bounded work it is the right one. You break a long loop into batches, hand control back to the browser between them with requestIdleCallback or a setTimeout(0), and let paint and input run in the gaps. For an incremental job — reindexing the handful of notes that changed since the last edit — this keeps the frame budget intact without any of the machinery a worker demands.

The ceiling is that it is still one thread. Slicing spreads the same total work across more frames; it does not make the work cheaper or move it elsewhere. When the job is large and the user is actively typing, the browser now has to interleave your index chunks with real rendering, and every chunk you run is a chunk of frame time the cursor does not get. Worse, the coordination itself has overhead, and requestIdleCallback fires only when the main thread is idle — precisely the moment a busy editor rarely reaches. For work that is genuinely long and genuinely CPU-bound, the honest fix is not to slice it thinner. It is to run it on a different thread.

Move parse, index, and search into a worker

The fix is to run the heavy work somewhere other than the thread that draws the screen. A Web Worker executes script on a background thread, so a long parse or search no longer blocks rendering. The main thread stays free to paint, and the frame budget survives.

The HTML Living Standard defines a worker as exactly this separation: an "API for running scripts in the background independently of any user interface scripts." 3 The spec spells out the payoff — it "allows for long-running scripts that are not interrupted by scripts that respond to clicks or other user interactions." 3 A full-text index build is a long-running script. Move it into a worker and the clicks keep responding.

The wiring is small. The main thread owns rendering and forwards commands; the worker owns the index and the parser and never touches the DOM.

// main thread — stays free to render
const worker = new Worker(new URL('./index.worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (e) => paintResults(e.data);   // cheap: just draw what came back
worker.postMessage({ cmd: 'search', query });      // heavy work happens off this thread
// index.worker.js — parse / index / search never touch the UI thread
let index;                                          // built once, kept in the worker
onmessage = (e) => {
  const { cmd } = e.data;
  if (cmd === 'build')  index = buildIndex(e.data.notes);      // long task, off the main thread
  if (cmd === 'search') postMessage(index.search(e.data.query));
};

Which search library you run inside that worker is a separate decision, benchmarked in full-text search in the browser, benchmarked. 4 The threading choice here is orthogonal: it is about where the work runs, not which library does it. The same applies to the parser — the correctness edge cases that make Markdown parsing expensive are their own subject, covered in parsing Markdown edge cases that break editors. 5

Here is where the naive version goes wrong. Moving the work is only half the transaction. The other half is moving the data, and the data is where the cost hides.

The trap: postMessage copies, it does not share

Moving work to a worker is not free, because the data has to cross a thread boundary. By default postMessage copies everything you send through structured cloning, and that copy cost scales with payload size. Send a large enough index and the clone alone can blow the 16-millisecond frame budget you were trying to protect.

MDN is explicit about the semantics: when you post a message to a worker, "the data is copied rather than shared." 6 The two threads do not touch the same bytes. Each postMessage serializes the object on one side and rebuilds it on the other. For a small search query this is nothing. For a serialized index measured in megabytes, it is a fresh long task — on the main thread, in the exact place you were trying to keep clear.

How much does the copy cost? A useful rule of thumb from Surma's investigation of postMessage is that "the stringified JSON representation of an object is roughly proportional to its transfer time." 7 Bigger object, longer copy. He also bounds where the copy is safe: "even on the slowest devices, you can postMessage() objects up to 100KiB and stay within your 100ms response budget," and, tighter, "if you have JS-driven animations, payloads up to 10KiB are risk-free." 7 Ten kilobytes is fine. A whole index is not. Cross that threshold and the copy becomes the bottleneck you introduced by trying to remove one.

Transfer the buffer, don't clone it

The escape from the copy tax is to transfer the buffer instead of cloning it. Hand postMessage a transfer list and the underlying memory is moved between threads in a zero-copy operation, not duplicated. The catch: after the transfer, the original buffer is detached on the sending side and can no longer be read.

MDN describes the mechanism precisely. With a transferable object, "the memory resource that it points to is literally moved between contexts in a fast and efficient zero-copy operation." 8 Nothing is serialized; ownership of the memory simply changes threads. The trade-off is total and by design: "following a transfer, the original object is no longer usable; it no longer points to the transferred resource, and any attempt to read or write the object will throw an exception." 8

In practice you serialize the index into an ArrayBuffer and pass that buffer in the second argument to postMessage — the transfer list.

// move a big buffer instead of cloning it
const bytes = new Uint8Array(serializedIndex);          // e.g. an index blob to hand off
worker.postMessage({ cmd: 'load', buf: bytes.buffer }, [bytes.buffer]);  // 2nd arg = transfer list
// after this line bytes.buffer is DETACHED on the main thread — do not read it again

The mental model is a handoff, not a photocopy. A copy leaves you holding the original and pays for a duplicate; a transfer gives the memory away and pays almost nothing. For anything large enough to matter to a frame, transfer is the difference between a stutter and a smooth handoff.

There is a third option worth naming, with a real cost of its own. A SharedArrayBuffer is not copied and not detached — both threads read and write the same memory at once, which is the only way to avoid handing ownership back and forth when both sides genuinely need live access to the same bytes. The price is operational: a page can only allocate one if it is cross-origin isolated, which means serving the right Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers, and it introduces the ordinary hazards of shared memory, race conditions and the need for atomics. For a note editor that hands a finished index across once, transfer is simpler and sufficient. Reach for shared memory only when a single owned buffer, moved on demand, is genuinely not enough.

flowchart TD
  A[Main thread<br/>draws at 60fps] -->|postMessage query| B[Web Worker]
  B --> C[Parse, index, search<br/>off the main thread]
  C -->|transfer buffer<br/>zero-copy| A
  A --> D[Paint results<br/>frame stays smooth]

Figure: The off-main-thread loop. The main thread posts a small query to the worker, the worker parses, indexes, and searches on a background thread, then hands a result buffer back by transfer (zero-copy) rather than by clone, so the main thread keeps drawing frames on time.

Benchmark: what the copy actually costs

In a round-trip benchmark, moving an 8-mebibyte buffer through structured-clone copying cost about 37 milliseconds per trip, while transferring the same buffer cost under 1 millisecond — a difference of roughly 47 times. One number sits well outside a 60fps frame; the other disappears into it.

The measurements below are a worker round-trip — send a payload, echo it back — over 200 to 2,000 iterations:

Payload (round-trip)Structured-clone copyTransferable (zero-copy)Ratio
8 MiB~36.9 ms~0.78 ms~47×
64 KiB~0.78 ms~0.06 ms~13×
empty message~0.058 ms (fixed overhead)

The shape is the point. Copy cost climbs with payload size, exactly as the stringified-JSON rule of thumb predicts. 7 Transfer stays flat and near-zero regardless of size, because no bytes move — only ownership. At 8 MiB the copy path alone spends more than twice a full frame budget; the transfer path spends a fraction of one. The empty-message row isolates the irreducible per-message overhead: even sending nothing costs a fixed sliver, which matters later.

Two design rules fall out of the table. First, size your messages. A search query is a few dozen bytes and can be cloned freely; a serialized index is megabytes and must be transferred. Match the transport to the payload, not to a blanket policy. Second, count your messages. The fixed per-message overhead means a thousand tiny round-trips can cost more than one batched round-trip carrying the same data, so prefer sending one array of results over posting each result as it is found. The two rules compose: keep messages few, and make the large ones transfers.

Now the honest caveat, because it is load-bearing. These numbers were measured with Node's worker_threads on a development machine, not with a browser Web Worker, and not on fixed hardware. Node and the browser share V8's structured-clone and transferable semantics, so the direction transfers reliably — clone scales with size, transfer does not. The exact multiple does not: a second run on a different harness measured the same 8 MiB gap at closer to 14×, not 47×, because the copy path's cost shifts with how the payload is allocated and how the runtime is warmed. Read the table as an order-of-magnitude illustration of the copy-versus-transfer gap, not as a browser frame number. The headline measurement a real editor needs — main-thread versus worker frame times for your parse, index, and search on your vault — is still yours to run in your target browser, with performance.now() around the work and the DevTools performance trace open. The harness that produced the table is small:

// core of the copy-vs-transfer harness (Node worker_threads; re-run in-browser to compare)
w.postMessage({ data: base.slice() });        // COPY path: structured clone on every send
w.postMessage({ buf: ab }, [ab]);             // TRANSFER path: zero-copy, ab detached after

When not to move work off the main thread

A worker is the wrong tool for small, chatty messages. It carries a fixed per-message overhead and a real start-up cost, and the spec itself calls workers heavy-weight. If the work you offload is smaller than the cost of shipping it across the boundary, the main thread was the faster place all along.

The HTML Living Standard says so in the same breath it introduces workers: they "are relatively heavy-weight, and are not intended to be used in large numbers." 3 Its example is deliberately absurd to make the point — "it would be inappropriate to launch one worker for each pixel of a four megapixel image." 3 The overhead is not just spawning. It is every message: the empty-round-trip row above shows a fixed per-message floor that a tight loop of tiny messages will pay over and over, until the coordination costs more than the computation.

There is also a start-up cost to account for. Spawning a worker loads and parses its script before it can do anything, so a worker created on the first keystroke pays that latency at the worst possible moment. The usual answer is to keep a warm worker: create it once at load, hand it the index, and reuse it for every query rather than spawning one per search. The heavy-weight nature the spec warns about is a reason to have few, long-lived workers, not many short-lived ones.

Surma's conclusion is the balanced version of the same truth: postMessage "does have a cost, but not the extent that it makes off-main-thread architectures unviable." 7 The rule is not "always use a worker." It is: offload work that is genuinely long, batch messages so you cross the boundary rarely, and transfer anything large. For the question of how many workers to run at all, a 2016 study by Verdú and Pajuelo found the best scaling at roughly one worker per CPU core 9 — a ceiling worth knowing before you spawn a pool, and consistent with the spec's warning against treating workers as cheap and infinite.

Frequently Asked Questions

How do I stop my note editor from freezing on a large vault? Move the CPU-bound work — parsing Markdown, building the search index, running queries — off the main thread into a Web Worker. The main thread's only time-critical job is drawing the next frame within about 16 milliseconds. A long parse or index build that runs there drops frames; the same work in a worker runs on a background thread and leaves rendering untouched. 1 3

Is postMessage slow? It has a real cost, but not a disqualifying one. By default postMessage copies your data through structured cloning, and that copy time scales with payload size. 6 For small messages the cost is negligible — under about 10 KiB is risk-free for animations. 7 For large buffers, transfer instead of copying, which moves the memory in a zero-copy operation. 8 The cost only bites when you send large payloads by clone.

What is the difference between transferable objects and structured clone? Structured clone copies the bytes: both threads end up with independent duplicates, and the copy takes time proportional to size. 6 A transfer moves ownership of the underlying memory in "a fast and efficient zero-copy operation" with nothing serialized. 8 The trade-off is that the original is detached — "the original object is no longer usable" after transfer and will throw if you read it. 8

How much time do I have per frame at 60fps? About 16 milliseconds total per frame, but the browser needs roughly 6 of those to lay out and paint, leaving an app "about 10 ms to produce a frame." 1 Separately, any single task over 50 milliseconds counts as a long task and will visibly block interaction. 2 Both numbers matter: budget for 10 milliseconds of your own work, and never let one task run past 50.

When should I not use a Web Worker? When the messages are small and frequent. Workers are "relatively heavy-weight" per the HTML spec, and every postMessage carries a fixed overhead. 3 If you send many tiny messages, the per-message and copy costs can exceed the work you moved, making the main thread faster. Offload genuinely long tasks, batch your messages, and transfer large buffers rather than cloning them.

How many Web Workers should I create? Roughly one per CPU core is a sensible ceiling. A 2016 analysis of JavaScript worker scaling found the best performance at a worker count equal to the number of cores. 9 Beyond that, extra workers contend for the same cores and add coordination overhead without adding throughput — and the spec explicitly warns against spawning them in large numbers. 3

The main thread has one job the user can see: draw the next frame on time. Everything else — parsing, indexing, searching — is work that can wait in another thread, as long as you move the buffer instead of copying it.


MNMNOTE runs as a local-first Markdown editor in your browser, where your notes stay on your own device — mnmnote.com.

Footnotes

  1. "Measure performance with the RAIL model," Chrome team, web.dev. https://web.dev/articles/rail. Updated 2020-06-10. Accessed 2026-07-25. 2 3 4 5

  2. Wagner, J. & Kenny, B. "Optimize long tasks," web.dev. https://web.dev/articles/optimize-long-tasks. Updated 2024-12-19. Accessed 2026-07-25. 2

  3. "Web workers," WHATWG HTML Living Standard, Introduction. https://html.spec.whatwg.org/multipage/workers.html. Accessed 2026-07-25. 2 3 4 5 6 7

  4. MNMNOTE. "Full-Text Search in the Browser, Benchmarked." https://blog.mnmnote.com/posts/full-text-search-in-the-browser-benchmarked. Accessed 2026-07-25.

  5. MNMNOTE. "Parsing Markdown Edge Cases That Break Editors." https://blog.mnmnote.com/posts/parsing-markdown-edge-cases-that-break-editors. Accessed 2026-07-25.

  6. "Using Web Workers," MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers. Accessed 2026-07-25. 2 3

  7. Surma. "Is postMessage slow?" surma.dev, 2019-07-15. https://surma.dev/things/is-postmessage-slow/. Accessed 2026-07-25. 2 3 4 5

  8. "Transferable objects," MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects. Last modified 2025-09-18. Accessed 2026-07-25. 2 3 4 5

  9. Verdú, J. & Pajuelo, A. "Performance Scalability Analysis of JavaScript Applications with Web Workers," IEEE Computer Architecture Letters, 2016. DOI 10.1109/LCA.2015.2494585. https://ieeexplore.ieee.org/document/7307120/. Accessed 2026-07-25. 2