All writing
5 min readEngineeringPerformance

Why your notes app gets slow at 500 pages

Most tools in this category are fast in a demo and heavy in a real workspace. Usually it is one table doing two jobs.

Note-taking tools are fast when you try them and slow when you live in them. The demo workspace has nine pages; yours has six hundred, some of them long, and typing has started to feel like it is being negotiated.

There are many causes, but one is common enough to be worth naming: a single table holding both the structure and the content.

The shape of the problem

A sidebar showing your page tree has to subscribe to something, and the obvious something is the pages table. Every row: title, icon, parent, order — and body.

Now type a character. The body changes, so the row changes, so the subscription fires, so the server sends the rows again. To redraw a list of titles, every client with that workspace open receives a copy of every document in it. At nine pages this is invisible. At six hundred it is a keystroke pushing a few megabytes down a socket, repeatedly, while you are still typing.

Two tables, one subscription each

In Nex, pages and page bodies are separate tables. The sidebar subscribes to the structure — title, icon, parent, order, nothing else. The open document fetches its own body through a different query.

That split is not tidiness, and the codebase says so at the top of the file. It is the difference between a keystroke costing one small write and a keystroke costing a copy of everything you have ever written.

What follows from it

Once the split exists, it starts making other decisions for you, and this is the sign of a load-bearing one.

The “last edited” timestamp lives on the body table, not the page row — putting it on the row would re-send the whole tree on every debounced save, which is the exact problem the split was for. Word counts are derived when read, not stored, because a stored count is a write per keystroke and still stale for anyone who has not caught up. Full-width is on the body. The plain-text search field is on the body. Anything that changes when you type belongs on the side of the line that nobody is watching.

The other half: what the client keeps

Which page is open lives in the URL, because that is the one piece of view state a person expects to bookmark and send to themselves. Which rows are expanded lives in local storage, because that is a fact about a window rather than about the document — and a person with two windows open wants two different answers.

Neither belongs on the server. A workspace that syncs your scroll position between machines has confused “my data” with “my session”, and it pays for the confusion with a write every time you click a triangle.

The general rule

Ask what re-renders when a keystroke happens, and follow it all the way back to the database. Nearly every performance cliff in an app like this is a row that is subscribed by something that does not need most of it. The fix is boring and structural, which is why it is easier to do early than to retrofit at six hundred pages.