Changelog 0004: Underappreciated aspects of text editor performance
Follow us on Twitter and/or Bluesky.
This week, the team is hard at work on quality. That means stuff like text editor performance. This subject is fun, so rather than show screenshots or demos, I’m going to talk about the most surprising I’ve learned about text editor performance so far.
If you want to run 60 fps, most technology is simply off the table
Running at 60 fps means you have ~16ms to complete all your work before rendering the next frame. In many apps you can get away with a lower framerate, but in systems like text editors and video games, users will generally notice when you start dropping frames.
The surprising part of this, to me, was just how few technologies are really built for this use case. Everything from state management systems to authentication libraries are built with a totally different kind of workload in mind.
Yes! Authentication libraries! Really!
Here, for example, is a slightly editorialized snippet from our own codebase. Asking our identity provider’s core library for an access token in some cases has a noticeable impact (as in, to users!) on typing speed!
Every engineering team has a healthy amount of push-and-pull, a kind of logistical annealing to get the features you want, but at an appreciable cost. You find yourself having these conversations way more often when you work on a text editor, and about stuff that you would generally not expect.
That includes many collaborative editing algorithms
Here’s a question you should ask your local text editor engineer: does your text editor start dropping frames when you have many people editing (or even just watching)? Obviously, weird stuff will happen to every product like this at some appreciable scale, but a thing that was surprising was just how quickly a lot of off-the-shelf collaboration libraries start to break down.
Now, I’m going to say something and it’s going to be controversial. I apologize. I know people work on this stuff really hard, and I know many of these libraries have since invested a lot in perf. I am not trying to hurt anyone’s feelings.
BUT, when we benchmarked the typical collaborative text editing libraries (Yjs, Automerge, and a few others), we found that after even tens of concurrent editors, the editor framerate dropped noticeably, often to < 5 fps. Performance revealed that, for the CRDT libraries at least, most of this time was spent just reconciling changes.
I am not sure why so few people talk about this.
I have only seen one person on the Internet ever mention this: the author of the @stepwise/prosemirror-collab-commit library explaining in this obscure comment why they cap the number of steps sent from the server to the client for them to apply:
The use case for this setting is documents with a high number of active editors each with variable network conditions and high edit velocity.
Example:
- A 50+ person meeting where members are all actively editing the document adding updates, ideas, and anecdotes
- 60fps update target affording at most 16.66ms of JavaScript execution per tickThe commit represents a unit of work that must be applied atomically. Limiting the number of steps in each commit is a tunable to facilitate more fine grain interleaving of edits on the backend, and lower latency application of the broadcast commits on the client side.
Applying a single step, full document paste uses far less resources(CPU time and memory) than processing the 60k individual steps that originally constructed the document. Limiting commit size helps limit "apply" time of any one commit and thus helps mitigate typing latency(keystroke to paint). Smaller commits provide a "smoother" editor experience compared to "chunky" updates when integrating remote commits.
Now there are two places. Or three, if you count me quoting it again in our CascadiaJS talk.
You can still (probably) afford to re-render the entire app in a single pass??
Profile your shit. You will surprise yourself. I promise. One of the biggest surprises for us was how little the React render model seems to really get in the way of performance.
If you don’t know, unless you configure it to do something else, React will generally render your app in a single, giant, monolithic pass over your component tree. My intuition was that this would matter a lot, and the truth is, in our testing, it… doesn’t???
Or, more precisely, it does, but only for large (>1Mb of plain-old text) documents. And in that case, the implementation of ProseMirror’s EditorView that we use is actually slightly faster than the plain-old JavaScript version that is in ProseMirror upstream. I am sure this is not inherent to the upstream version, since CodeMirror is, like, orders of magnitude faster. But this is a good inverse lesson, too. You are never sure you have a performance issue until you actually benchmark.