Two perf bugs in Open Notion

Stress-testing Open Notion — my Tiptap-based block editor — surfaced a class-field-init bug deep in Tiptap and a Shiki bottleneck on the main thread. The diagnosis, and the two fixes that took typing back to zero INP.

read
9 min

I built open-notion, a Tiptap-based block editor that powers the writing surface in my own CMS. Three pnpm packages: the editor with menus and chrome, a serializers package that turns the JSON document into HTML / Markdown / text / a React tree / PDF, and a thin assets package with the shared CSS and a tiny rehydration script. Tiptap v3 underneath, ProseMirror under that.

💡

This post is about two specific performance problems I hit during stress testing. One of them turned out to be a bug inside Tiptap itself. The honest framing up front: no real document will ever hit the conditions that reproduce these bugs. The editor would have shipped fine without either fix. I fixed them anyway, because once you can see the work on the call graph you can't unsee it.

The stress test that broke an assumption

I started with the dumbest test possible. A thousand paragraphs in a document, typing at the end. Smooth. INP basically zero, no perceptible delay. Good — that's the expected ProseMirror story.

Then I swapped in custom nodes — callout, code block, image — the ones rendered through ReactNodeViewRenderer. About three hundred of them. Each one a single line, no content inside. Typing at the bottom of the document was still fine. Typing at the top hit roughly 500ms INP per keystroke. Visibly slow. Unusable.

A 4× drop from a thousand plain blocks to three hundred custom ones is not a content-volume problem. The only thing different was that the custom blocks went through React, while plain paragraphs went straight through ProseMirror. Something in the React path was doing O(N) work per keystroke.

What I assumed first, and why it was wrong

The boring theory first: React was re-rendering every custom NodeView on every keystroke, and I had forgotten to memoize. Easy fix. Wrap the NodeView with memo and a custom comparator, ship it.

The NodeView was already memoized. With a custom comparator. The comparator was running on every keystroke and returning true for every prop except one: a callback whose return value was identical to the previous render's, but whose reference was new every time. === doesn't care about return values, it cares about function identity, and that function was a new closure every render.

So the memo was correct but useless. The re-renders were real.

Before guessing at the upstream cause I wanted to see the shape of the work. I dropped a console.log inside the comparator and started typing at different cursor positions:

  • Cursor at the bottom of the doc: zero logs per keystroke.

  • Cursor in the middle: about half the custom blocks logged.

  • Cursor at the top: all three hundred logged.

The pattern was the cursor's position fanning forward. Every custom NodeView after the cursor was getting its props recomputed on every keystroke. That's the signature of position-driven updates — something was sending props to every NodeView whose ProseMirror position had shifted, and every keystroke at the top of a 300-block doc shifts 300 positions.

The DevTools Performance tab confirmed it: one long task per keystroke, most of it in React's commit path, all of it inside the custom NodeView wrapper.

The actual cause

I read Tiptap's ReactNodeView source. Two paths were both calling renderer.updateProps per transaction on every position-shifted NodeView.

The first one I expected. ReactNodeView.update(node, decorations, innerDecorations) is ProseMirror's standard NodeView lifecycle hook. Tiptap's default implementation calls rerenderComponent unconditionally on a position shift, regardless of whether content actually changed. So for any edit that shifts N positions, you get N updateProps calls. Annoying but expected — there's an update option on ReactNodeViewRenderer that lets you intercept it.

The second one I did not expect. Tiptap also has a positionCheckCallback that's supposed to be cancelable. The cancel hook expects the closure reference, and the reference is supposed to live on the NodeView instance as this.positionCheckCallback. Look at the constructor:

js
class ReactNodeView extends NodeView {
  constructor(...args) {
    super(...args);
    // base constructor calls this.mount() internally:
    //   this.positionCheckCallback = closure
    //   schedulePositionCheck(editor, closure)

    this.positionCheckCallback = null;
    // class-field initializer runs AFTER super returns,
    // overwriting the field that mount() just assigned.
    // The closure is still registered in the per-editor set,
    // but `this.positionCheckCallback` is now null and the
    // cancel hook can't reach it. Leak.
  }
}

This is a JavaScript class semantics rule I had never thought about. Per the spec, class-field initializers run after the super() call returns, in declaration order. If the base constructor mutates a field (it does, indirectly through this.mount()), and the subclass declares the same field with an initializer (it does), the subclass's initializer wins. The closure is still alive inside Tiptap's per-editor positionCheckCallback registry, but the NodeView no longer has a handle to it, so cancelPositionCheck(editor, this.positionCheckCallback) cancels null — i.e., nothing.

Every NodeView leaks a closure into a per-editor registry. The registry walks every closure on every transaction whose position map is non-identity. Each closure calls renderer.updateProps({ getPos }). That's the second O(N).

I will admit I didn't figure this part out on my own. I dumped the call stack and the relevant Tiptap files into Claude and asked it to find why my closures weren't getting cancelled. It pointed at the field-initialization order. I had to verify the claim against the spec, but the moment I saw the pattern I felt slightly stupid for not seeing it earlier. Touching the internals of a library this size and not using everything available to read faster would have been a worse choice.

The fix

There's no clean upstream fix without changing Tiptap's constructor, and I wasn't going to fork Tiptap for a stress-test improvement. The wrapper reactNodeViewLite patches both paths from the consumer side.

Path 1 is easy. Pass an update option that short-circuits on identity-equal nodes and reference-equal decorations:

ts
ReactNodeViewRenderer(Component, {
  update: (oldNode, newNode, decorations, innerDecorations) => {
    if (oldNode === newNode && oldDecos === decorations) {
      return true;        // nothing actually changed — skip
    }
    return false;         // fall through to default rerender
  },
});

ProseMirror nodes are immutable. If content hasn't changed, the node identity hasn't changed. If decorations haven't changed, their reference hasn't changed. Real changes — emoji swap, code-block language, image resize — fall through and update normally.

Path 2 is uglier. The leaked closure produces exactly one shape of payload, { getPos }, and every legitimate caller of renderer.updateProps passes either multiple keys or { selected }. So the wrapper patches each NodeView instance's renderer.updateProps to drop that exact shape:

ts
const original = nv.renderer.updateProps;
nv.renderer.updateProps = (next) => {
  const keys = Object.keys(next);
  if (keys.length === 1 && keys[0] === "getPos") return;
  return original.call(nv.renderer, next);
};

That isn't elegant. It's a wart against a specific bug in a specific Tiptap version. The comment block above the wrapper spells out which version it's patching and what to re-check on upgrades, so when Tiptap fixes the underlying issue I can rip the patch out without archaeology.

After this landed, the stress test went from ~500ms INP at the top of a 300-block doc to about 30ms. Smooth.

The second profile, which had a different villain

That should have been the end of it. It wasn't. On a different test — a document with a mix of paragraphs and ten medium-sized code blocks — INP was still in the low hundreds. Different cause, same symptom.

Performance tab again. About 70% of the long task was inside one function: Shiki's tokenizer.

The setup at that point was a ProseMirror plugin that ran Shiki on the document and produced highlighting decorations. Standard pattern. The plugin fired on every transaction. It re-tokenized every code block in the document on every transaction. So typing one character into a paragraph re-highlighted every code block in the document, even though the code blocks hadn't changed.

That's a plugin-scope bug, not a Tiptap bug — my own code being too coarse with what it considered dirty. The fix was structural: pull highlighting out of the transaction handler and into the plugin's view, then scope dirty-tracking to the specific code block being edited. A WeakMap<Node, string> keyed by ProseMirror node identity gives a free "has this content changed?" check, because content changes produce a new node reference and the old entry is garbage-collected when the old node is.

Typing in a paragraph triggers nothing. Typing in code block A retokenizes code block A only.

That alone fixed the typical case. INP went back to near zero on any realistic content. If I had stopped here the editor would have been done.

The web worker, which I admit was over-engineering

The remaining failure mode was a single large code block. A 200-line code block being edited still produced ~100ms INP on the typing thread because tokenizing a long string is intrinsically expensive.

First attempt: switch from Shiki's JavaScript engine to its WASM (oniguruma) engine. Bigger bundle (~230 KB gzipped vs ~20 KB), several times faster per token. That bought me roughly 6–7× the code block size before lag came back. Past that, same problem at a higher ceiling.

The real fix, the one with no ceiling, was a web worker. I had never used web workers before. I had read about them a dozen times and built nothing with them. So I built one, partly because it was the right answer and partly because I wanted to know what shipping one actually felt like.

The arrangement is per-editor-view. The code-block extension spawns a dedicated Worker inside the plugin's view(editorView) hook and tears it down in the returned destroy(). If you capture the worker in the plugin's outer closure instead, the first destroy() call — which happens on any editor.setEditable(false) cycle — terminates the worker for the lifetime of the editor and breaks every subsequent view. Per-view ownership matches ProseMirror's plugin-view lifecycle and avoids that class of bug.

The worker imports @open-notion/serializers/highlighter and runs the engine. The main thread sends a message with the language and the source string; the worker sends back token spans; the main thread applies those as ProseMirror decorations. The original document text is untouched, so cursor, selection, copy, and paste all keep their native behavior — decorations are a view-only projection.

Result: arbitrary code-block size, zero INP from tokenization on the typing thread. There's a brief moment of unstyled text on the JS engine while the worker spins up; on WASM it's not perceptible. I'll take it.

What I'd cop to

A real document will not have 300 callout blocks. A real document will not have a 500-line code block. The conditions that reproduce both of these bugs are pathological. The first fix matters for documents with maybe 20–30 custom blocks where the lag was 60–80ms — perceptible but not awful. The second fix matters even less for almost everyone. The web worker matters basically never.

I shipped them because I had already done the diagnosis, and the work that went into reading Tiptap's NodeView source paid off in unrelated ways elsewhere in the project, the way internals-reading always does. I also wanted to know what web workers felt like.

The editor is open at github.com/Tabsir99/open-notion, published under @open-notion/{editor,serializers,assets}. The bundle is bigger than I'd like — the cost of shipping a full Notion-style feature surface — but most of it is lazy: menus, node views, the syntax highlighter, the emoji catalog, the PDF renderer all stream in on first use. Initial mount is around 50 KB gzipped.

I'm not pitching it. If you're building something that needs a Notion-style editor and you'd rather not assemble it from extensions yourself, give it a try. If one person finds it useful, that's enough reason for it to exist.