Changelog 0005: Thanksgiving and more perf stuff
Follow us on Twitter and/or Bluesky.
American Thanksgiving! One of the few of weeks of the year you can expect that more-or-less the entire Moment team is out on vacation, at least for a large portion of the week. Thus, the skeleton crew that is here this week interrupts your normal programming to talk more about fun perf stuff. Yes, like last week.
This week we’ll talk specific perf benchmarks, and even show (a lightly idealized version of) our core EditorState update loop.
How fast does this thing go, anyway?
In the last section of last week’s update, we mentioned that React’s single-pass, monolithic render step is “probably” fast enough for your purposes. At least, unless you intend to support something more exotic than a 60 fps editor, like us! But how fast is fast enough?
As we said in that section: profile your shit. In our push to retool our frontend state management system, we wrote a bunch of benchmark tests and found, roughly, that:
Baseline perf is good: in a plain-old JavaScript implementation of ProseMirror the median transaction (a couple of characters in the middle of a doc) on the median document (a few kilobytes) will take a few microseconds. Depending on the tests, between 3 and 15µs. At this rate, you will be able to accept around 30,000 transactions in 70 ms.
Perf with ProseMirror and Jōtai as state management is also good: the same conditions produces transactions between about 4.5 and 15µs. At this rate you can accept around 15,000 transactions transactions in 70ms.
Perf with ProseMirror and Jōtai and React is also good: the same test, with React, will generally also take a few microseconds. Again, depending on the test, between 7 and 20µs. At this rate, you can accept around 10,000 transactions in 70ms.
Perf on extremely demanding documents is much slower. For example, if you have a lot of text, or very complicated layout, edits can take an arbitrarily-long amount of time
If you are curious the benchmark tests generally look a lot like this:
Tips for keeping this latency down
The basic idea of Jōtai is that you have “atoms” that are reactively updated. So here, any time $count is updated, we automatically re-run $message, too.
The main “tricks” we use to keep latency down are:
Atoms responsible for managing ProseMirror EditorState objects have zero dependencies. Jōtai allows you to have totally-isolated, tiny islands of state, which are very cheap to update. But it only stays fast and predictable if you don’t spuriously re-compute atom values, or recompute a lot of stuff on each keystroke. Remember that every call to get can cause spurious re-renders, and both reads and writes must execute the get to complete.
Optimistically update in-memory cache first, then do async actions like persist. Our slightly-editorialized core editor loop look like the following. The details are not so important, just note that we’re calling .apply in the so-called “synchronous zone”, while onPersist and sendToCollab happen after. Since Jōtai writers can run simultaneously, the editor loop is very fast to reflect changes, and the persist callbacks are as slow as the network needs them to be. This is almost exactly the code we have that supports tens of thousands of transactions a second on the median document.
If you are like me, you are vaguely surprised this code is so simple. Text editors are kind of funny in this way. They generally aren’t that much code. The struggle is almost always in finding precisely which lines of code to write.