Synchronous status-bar backlink counting blocks note switches for ~5 seconds in a 6.6k-note vault
ZenNotes 2.59.0 on Arch Linux / Hyprland / Wayland takes about five seconds to display a different note in a real vault with 6,577 Markdown notes. This affects small notes and cached note switches too. Renderer CPU profiling and a controlled temporary app copy isolate the bottleneck to status-bar backlink counting.
Environment
- ZenNotes 2.59.0, Arch package
zennotes-bin 2.59.0-1.
- AMD Ryzen 5 5600GT, 12 logical CPUs, 31 GiB RAM.
- Local ZFS storage; no network mount.
- Hyprland compositor, Wayland session, AMD integrated graphics. Other Linux setups may use a different compositor, display protocol, or graphics backend; this environment may matter for separate rendering/startup problems. The actual Electron display backend was not independently verified, so a Wayland desktop session should not be taken as proof that the app ran natively on Wayland.
- 6,577 Markdown notes, about 63 MB total Markdown, median note size 4.7 KB.
- Cached metadata: 13,672 wikilink target entries in 4,364 notes, 11,144 unique target strings.
- 2,318 folders and 7,907 assets returned by the bridge.
This is a mixed-use vault containing large media files and programming artifacts, including node_modules, .git, and .venv. The initial note scan indexes 86 Markdown notes inside node_modules; dot-prefixed directories such as .git and .venv are skipped. Large binary assets are not read as Markdown note bodies for backlink calculation. Asset traversal and the Linux desktop environment may contribute separately to startup or rendering costs, which have not been fully isolated. The backlink bypass comparison below used the same machine and desktop session.
Reproduction and expected behavior
- Open a vault containing several thousand notes and many distinct wikilink targets, including unresolved and path-qualified links.
- Switch between different small notes, including previously opened notes.
- Observe the delay between clicking and the new note painting; also expand a folder that selects a note.
Expected: opening an already indexed note remains responsive, without synchronously resolving every link in the vault again. Actual on the profiled vault: roughly five seconds per note switch.
Results
I adapted tooling/scripts/perf-desktop-runtime.mjs to retain CPU profiles and partial timings, sample multiple visible note rows, and send Ctrl+P rather than Meta+P on Linux. Runs used isolated app profiles and the real vault, with no note editing.
| Measurement |
Installed app |
Temporary app with backlinks bypassed |
| Confirmed different-note samples |
7 |
7 |
| Click-to-render median |
4,793 ms |
55 ms |
| Click-to-render range |
4,696–4,866 ms |
30–113 ms |
| Folder expansion |
4,633 ms |
35 ms |
| Note/folder refresh fetch |
1,005 ms |
966 ms |
In the original run, uncached note-open store timings were only 5.7–11.1 ms; cached timings were 1.7–5.3 ms. The wait occurred afterward in synchronous renderer work. The CPU profile of the folder-expansion/first-note probe attributed 94.2% of sampled time to the status bar's backlink memo callback.
The control used a temporary extracted copy of the installed app, replacing only backlinksForNote() with return []. It removes backlink functionality and is exclusively a diagnostic control. Installed files were untouched. One additional clicked row in each run lacked a retained note-open event and is excluded from the table.
Bypassing persisted metadata in a separate run did not eliminate the problem: three note switches had a median of 4,975 ms.
Code path
At checkout fcf5fb41 (ZenNotes 2.59.0):
- StatusBar.tsx synchronously computes
backlinksForNote(notes, note).length inside a render-time memo keyed on selected path and the note array.
- wikilinks.ts loops over notes and their wikilinks.
resolveWikilinkTarget() at line 189 filters the entire notes array, then searches titles or paths for every target.
This makes the worst-case scan proportional to link count multiplied by note count. Many unique or unresolved links incur repeated full-vault scans and string normalization. Even opening a note with zero backlinks still pays this cost. The StatusBar comment describing an O(n) scan appears to overlook this inner resolution.
A standalone calculation using the same source and cached real metadata took 5,739 ms on the full vault, 31 ms on a 1,225-note subset excluding a large archive, and 5,557 ms after excluding just the 86 node_modules notes. These are single-run Node timings, but they independently reproduce the dominant cost without disk reads inside the measured function.
Startup measurement gap
The app also feels slow at launch. The harness's renderer.workspace.ready mark is around 2 seconds, but it fires before the blocking editor/backlink render finishes. The startup CPU-profile phase lasted around 7.5 seconds in the original run and 2.4 seconds in the bypass control. These are renderer-reload measurements, not controlled full cold-launch times.
A benchmark that only asserts workspace.ready or the internal note.open.* duration can therefore pass while the UI remains blocked for seconds.
Possible fix using existing Atlas infrastructure
There is already a native Map/Set-based graph implementation in atlas.ts, so this should not require a new graph library. However, buildAtlasGraph() currently uses the same per-target resolver, and its sorted/deduplicated edge pairs discard link direction. Its graph is also owned by AtlasView, rather than shared with the status bar and Connections panel.
One possible direction is to extract a shared directed link index:
- Index normalized titles and paths once per metadata revision, preserving existing ambiguity, trash, anchor, and path-resolution behavior.
- Store outgoing and incoming relationships; the status bar reads an incoming count without rescanning the vault.
- Let Atlas derive its undirected visualization edges from the same relationships while retaining its existing layout code.
- Reuse the existing note metadata cache, which validates path, modification time, and size. Atlas also has a per-path/per-mtime Markdown-link cache as precedent.
- On additions, renames, deletions, and exclusions, reconsider affected resolutions, including unresolved links and duplicate titles. Content fingerprints alone are insufficient for these changes.
Workers and persisted graph state could improve responsiveness and startup further, but they are complementary to avoiding repeated resolution. This is a proposal rather than a requirement for a particular architecture.
Validation and related work
A regression workload should include thousands of notes and many distinct unresolved/path-qualified links. Measure different-note click-to-paint, cached switches, and folder-triggered note selection, rather than only store read completion. Preserve backlink correctness when targets are renamed/deleted or previously unresolved links become resolvable.
Related: #431 requests .zenignore and consistent exclusions. Exclusions would reduce the workload but do not replace fixing repeated backlink resolution: removing node_modules notes alone barely changed the standalone calculation above. That feature can remain a separate issue.
CPU profiles and aggregate measurement JSON were captured locally. Raw snapshots contain private note paths, so they are not attached here; the figures above contain aggregate data only.
Synchronous status-bar backlink counting blocks note switches for ~5 seconds in a 6.6k-note vault
ZenNotes 2.59.0 on Arch Linux / Hyprland / Wayland takes about five seconds to display a different note in a real vault with 6,577 Markdown notes. This affects small notes and cached note switches too. Renderer CPU profiling and a controlled temporary app copy isolate the bottleneck to status-bar backlink counting.
Environment
zennotes-bin 2.59.0-1.This is a mixed-use vault containing large media files and programming artifacts, including
node_modules,.git, and.venv. The initial note scan indexes 86 Markdown notes insidenode_modules; dot-prefixed directories such as.gitand.venvare skipped. Large binary assets are not read as Markdown note bodies for backlink calculation. Asset traversal and the Linux desktop environment may contribute separately to startup or rendering costs, which have not been fully isolated. The backlink bypass comparison below used the same machine and desktop session.Reproduction and expected behavior
Expected: opening an already indexed note remains responsive, without synchronously resolving every link in the vault again. Actual on the profiled vault: roughly five seconds per note switch.
Results
I adapted
tooling/scripts/perf-desktop-runtime.mjsto retain CPU profiles and partial timings, sample multiple visible note rows, and send Ctrl+P rather than Meta+P on Linux. Runs used isolated app profiles and the real vault, with no note editing.In the original run, uncached note-open store timings were only 5.7–11.1 ms; cached timings were 1.7–5.3 ms. The wait occurred afterward in synchronous renderer work. The CPU profile of the folder-expansion/first-note probe attributed 94.2% of sampled time to the status bar's backlink memo callback.
The control used a temporary extracted copy of the installed app, replacing only
backlinksForNote()withreturn []. It removes backlink functionality and is exclusively a diagnostic control. Installed files were untouched. One additional clicked row in each run lacked a retained note-open event and is excluded from the table.Bypassing persisted metadata in a separate run did not eliminate the problem: three note switches had a median of 4,975 ms.
Code path
At checkout
fcf5fb41(ZenNotes 2.59.0):backlinksForNote(notes, note).lengthinside a render-time memo keyed on selected path and the note array.resolveWikilinkTarget()at line 189 filters the entire notes array, then searches titles or paths for every target.This makes the worst-case scan proportional to link count multiplied by note count. Many unique or unresolved links incur repeated full-vault scans and string normalization. Even opening a note with zero backlinks still pays this cost. The StatusBar comment describing an O(n) scan appears to overlook this inner resolution.
A standalone calculation using the same source and cached real metadata took 5,739 ms on the full vault, 31 ms on a 1,225-note subset excluding a large archive, and 5,557 ms after excluding just the 86 node_modules notes. These are single-run Node timings, but they independently reproduce the dominant cost without disk reads inside the measured function.
Startup measurement gap
The app also feels slow at launch. The harness's
renderer.workspace.readymark is around 2 seconds, but it fires before the blocking editor/backlink render finishes. The startup CPU-profile phase lasted around 7.5 seconds in the original run and 2.4 seconds in the bypass control. These are renderer-reload measurements, not controlled full cold-launch times.A benchmark that only asserts
workspace.readyor the internalnote.open.*duration can therefore pass while the UI remains blocked for seconds.Possible fix using existing Atlas infrastructure
There is already a native Map/Set-based graph implementation in atlas.ts, so this should not require a new graph library. However,
buildAtlasGraph()currently uses the same per-target resolver, and its sorted/deduplicated edge pairs discard link direction. Its graph is also owned byAtlasView, rather than shared with the status bar and Connections panel.One possible direction is to extract a shared directed link index:
Workers and persisted graph state could improve responsiveness and startup further, but they are complementary to avoiding repeated resolution. This is a proposal rather than a requirement for a particular architecture.
Validation and related work
A regression workload should include thousands of notes and many distinct unresolved/path-qualified links. Measure different-note click-to-paint, cached switches, and folder-triggered note selection, rather than only store read completion. Preserve backlink correctness when targets are renamed/deleted or previously unresolved links become resolvable.
Related: #431 requests
.zenignoreand consistent exclusions. Exclusions would reduce the workload but do not replace fixing repeated backlink resolution: removing node_modules notes alone barely changed the standalone calculation above. That feature can remain a separate issue.CPU profiles and aggregate measurement JSON were captured locally. Raw snapshots contain private note paths, so they are not attached here; the figures above contain aggregate data only.