When I started optimizing the frontend of my knowledge base, I assumed tree generation would be the bottleneck.
It wasn't. The trees themselves were fast.
The real culprit? Vue's reactive system was constantly rebuilding them.
By rethinking how data was stored and controlled, I managed to drop the initial tree computation time from 270ms to 27ms (a 10x improvement), without touching a single line of UI code. Here's the architecture that made it possible.
It all started with three tables
It all started with three database tables.
- Categories
- Documents
- Uploads
Each represented a different type of object, each had its own API, and each had its own frontend store.
Everything looked perfectly reasonable... until I looked at the schema more closely.
Despite being different entities, they all had almost the same properties.
id
name
description
parent_id
icon
color
created_timestamp
updated_timestamp
// And more...
The only real difference was what they represented.
I realized that it would be much simpler if all these entities could be combined. This would avoid duplicating code, routes, the store, etc.
From three tables to one graph
Instead of storing categories, documents and uploads separately, I merged everything into a single nodes table.
Every object became a node with a specific type.
At first, the idea felt almost too simple.
Of course, merging several tables into one isn't free. Some columns inevitably become irrelevant for certain node types. For example, a category doesn't have any content, while a document does.
But in practice, this trade-off turned out to be much smaller than I expected. Empty columns take very little space in a relational database, and in return every node shares the same structure, the same API, and the same frontend model.
I also found that using generic field names made the model surprisingly flexible. Instead of having fields like document_content_md or document_content_html, I renamed them to more generic names such as content and content_compiled.
Different node types can then interpret those fields differently while keeping the same underlying schema. For a document, they store the Markdown source and its compiled HTML. For an uploaded image, the very same fields can store the original file URL and its optimized WebP version.
The database schema became less specialized, but the frontend became dramatically simpler because every object follows the same contract.
With this new single-table design, the backend was now sending a flat, unified stream of nodes to the frontend. It was elegant, but it shifted a massive burden onto the client: the frontend now had to organize this raw graph into readable structures.
The real problem wasn't SQL
The UI, however, didn't need one graph.
It needed many different trees.
For example:
- the workspace tree
- the sidebar hierarchy
- search results
- filtered views
Each represents the same data from a different perspective.
The obvious solution would have been to build each tree independently.
The graph solved one problem: every object now existed only once.
But the UI still had another requirement.
It wasn't enough to store the data efficiently.
It also had to expose multiple independent tree views, all backed by the same graph and all fully reactive.
That's where the real challenge began.
A graph is only half the solution
At this point I had a nice data model.
Everything was a node.
There was a single source of truth.
The backend was clean.
Unfortunately, the frontend still wasn't.
The problem wasn't building a tree.
Building a tree isn't particularly expensive.
Rebuilding it hundreds of times is.
The real problem was how often those trees were rebuilt.
If every small modification causes reactive computations across the entire application, performance quickly becomes unpredictable.
That completely changed the question I was trying to solve.
Instead of asking:
How can I build trees efficiently?
I started asking:
How can I make sure they are only rebuilt when they actually need to be?
My first implementation
Initially, every node lived inside a simple array.
const nodes: Node[] = [...];
It worked perfectly... Until the project grew.
Every lookup by ID became an O(n) operation:
- Finding children
- Finding parents
- Updating nodes
- Checking existence
Many operations slowly became O(n) or even O(n²).
The code stayed simple, but the data structure was no longer appropriate.
The obvious improvement
The next step was replacing the array with a Map.
const nodes: Map<string, Node> = new Map();
Lookups instantly became O(1).
Problem solved?
Not really.
Maps don't preserve a custom sort order, but the UI almost always required sorted nodes.
So the code constantly looked like this:
// Instead of a clean reactive flow, the code was doing this on every render:
Map -> Array -> Sort -> Render
The complexity had simply moved somewhere else.
Instead of paying for lookups, I was now paying for conversions and sorting.
Building an indexed collection
Eventually I realized I didn't actually need another container.
I needed a container that understood my access patterns.
I built a small abstraction that keeps one copy of every node while maintaining lightweight indexes alongside it.
private byParent = new Map<string, string[]>();
private byRole = new Map<number, string[]>();
private sortedArray: Node[] = [];
Notice that the indexes don't duplicate the nodes themselves. They only store IDs.
The actual objects still exist only once.
This turned out to be an important distinction because it keeps memory usage low while allowing different views of the same data.
The real optimization wasn't the indexes
Surprisingly, the biggest performance leap didn't come from faster lookups.
It came from controlling reactivity.
Vue is extremely good at updating the UI.
But if every insertion immediately triggers every dependent computation, even a good data structure becomes inefficient.
Imagine loading a workspace containing 200 nodes.
With a traditional reactive array, inserting those nodes one by one would notify every dependent computed property 200 times.
The tree generation itself isn't expensive.
Rebuilding it hundreds of times unnecessarily is.
Instead of relying solely on Vue's default reactivity, the collection exposes its own lightweight dependency system.
Internally, it tracks consumers using a single shallowRef, and only notifies them when the collection reaches a consistent state.
class IndexedCollection {
private isBatching = false;
private _dependents = shallowRef(null);
private notify() {
if (!this.isBatching) {
triggerRef(this._dependents);
}
}
startBulk() {
this.isBatching = true;
}
endBulk() {
this.isBatching = false;
this.rebuildSortedArray();
this.notify();
}
}
That made it possible to introduce bulk operations.
Begin bulk update
Insert 200 nodes
Update indexes
Notify once
Recompute once
Rather than triggering hundreds of updates during initialization, every dependent tree is rebuilt only once.
The same principle applies throughout the application.
Notifications are only emitted after actual CRUD operations that modify the database state.
As a result, the frontend always stays synchronized with the backend, while avoiding unnecessary reactive work.
The difference becomes obvious during the initial workspace loading.
Looking back, the biggest optimization wasn't algorithmic.
It was architectural: A different way of thinking about reactivity.
Instead of asking "How can I make tree generation faster?", I started asking "How can I make sure it only happens when it's actually needed?"
That single shift in perspective reduced the initial tree computation time from 270 ms to 27 ms, while keeping a single source of truth for the entire application.
If you want to dive deeper into the code, this entire architecture is open-source! You can check out the full implementation inside the Alexandrie project on GitHub: https://github.com/Smaug6739/Alexandrie.
If you found this breakdown helpful, feel free to drop a āļø on the repository or leave a comment below with how you handle complex reactivity bottlenecks in your apps!




Top comments (0)