Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md / TODO*.md so a future session can tell a live task from a finished record without reading the file to find out. TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted when the work lands. Enforced by the md-naming pre-commit hook and CI workflow, both delegating to the shared gate in kuhyx/utils so this repo's idea of a valid name cannot drift from every other repo's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
10 KiB
Linux desktop performance, part 2: interaction lag
The second half of the investigation started in desktop-performance-findings.md, which covers frame rate. Split only to keep each file under the 250-line cap; the author signposted the two parts explicitly.
Part 2: the interaction lag is a separate, larger problem
The 60 Hz cap above is real but is not what the user is complaining about. Driven by the user directly (synthetic input having proved unreliable), with an instrument that logs individual outliers plus event-loop blocking rather than per-second percentiles:
- Stalls are raster-dominated. Build (Dart) stays at 0.1-0.9 ms throughout; single frames reach 76 ms of raster at a 1280x1417 window.
- The UI isolate does block occasionally (65-101 ms at startup and on some interactions), but this is the minority of the problem.
- The user reports the lag is constant, on every interaction — which rules out both GitHub sync (periodic) and shader compilation (one-off, warms up).
- Impeller/Vulkan halved slow frames (18 → 7) and removed the worst 76 ms spike, but the user perceived no difference.
Raster cost scales hard with window pixel count, and the user runs the app fullscreen on the 4K monitor. Measured under a nested X server:
| Window | Achieved | build p50/p90 | raster p50/p90 |
|---|---|---|---|
| 1280x720 | 68.5 fps | 0.30 / 0.40 ms | 1.50 / 2.00 ms |
| 3840x2160 | 19.9 fps | 0.40 / 0.50 ms | 8.40 / 44.60 ms |
Those first 4K figures came from plain (software) Xephyr and overstated raster. Repeated fullscreen on the real 3840x2160 monitor:
| Window (real display) | Achieved | build p50 | raster p50 |
|---|---|---|---|
| 1280x720 | 68.6 fps | 0.30 ms | 1.50 ms |
| 3840x2160 | 18.6 fps | 0.30 ms | 2.70 ms |
This is the key result, and it is not a rasterisation wall. At 4K each frame costs ~3 ms of a 16.7 ms budget, yet the app achieves 18.6 fps (~54 ms per frame). Roughly 50 ms per frame is dead time — neither build nor raster. The bottleneck is the presentation/swap path, which is also why Impeller never helped: Impeller changes rasterisation, not presentation.
It is Flutter, not todo
Run through an identical harness, the stock flutter create counter app —
one spinner on a blank Scaffold, zero todo code — collapses the same way:
| App | 1280x720 | 3840x2160 |
|---|---|---|
| stock Flutter counter | 68.5 fps | 20.5 fps |
| todo | 68.5 fps | 20.6 fps |
Flutter's Linux embedder manages ~20 fps rendering a blank screen at 4K. Build time is 0.1-0.4 ms throughout. No application-level change can fix this.
Size curve (harness), showing the cliff begins at 1440p:
| size | Mpx | fps | frame cost | dead time |
|---|---|---|---|---|
| 1280x720 | 0.92 | 68.5 | 1.70 ms | 12.9 ms |
| 1920x1080 | 2.07 | 60.5 | 2.40 ms | 14.1 ms |
| 2560x1440 | 3.69 | 40.3 | 3.70 ms | 21.1 ms |
| 3840x2160 | 8.29 | 20.6 | 8.80 ms | 39.8 ms |
Related upstream reports: #66463 (Linux desktop failure based on window size on 4K monitors), #88996 (high frame latency on Linux desktop).
Test harness (for iterating without taking over the user's screens)
Xephyr -glamor runs a nested X server that renders through OpenGL on the real
GPU. Validated against ground truth: it reproduced the 4K symptom at 19.8 fps
versus the real display's 18.6 fps. It inflates raster attribution (7.2 ms vs
2.7 ms real), so use it for pass/fail, not for attributing where time goes.
Plain Xephyr without -glamor is a software X server and is useless here: it
copies a ~33 MB framebuffer per frame at 4K and dominates the measurement.
The harness is validated for the Flutter desktop embedder only. A Chrome run inside it reported 10 fps at 4K; measuring the same page on the real display gave 142.9 fps, confirming that number was a harness artifact (Chrome disables GPU acceleration on an unrecognised nested display). Never benchmark a browser inside it.
The decisive comparison: same content, same machine, real 4K display
The identical spinner probe, rendered at 3840x2160 on the real monitor:
| Renderer | Achieved at 4K |
|---|---|
| Flutter Linux desktop embedder | 20.5 fps |
| Flutter Web (CanvasKit) in Chrome | 142.9 fps (cadence p50 7.00 ms) |
A 7x difference, with the same framework and the same Dart code. This is not a hardware limit, a driver limit, or an application limit — it is specific to Flutter's Linux desktop embedder. It validates the web route at the size that actually matters.
Why the 60 Hz cap exists
libflutter_linux_gtk.so imports gdk_monitor_get_refresh_rate (and GTK
correctly reports 144.00 Hz here — verified independently), but imports no
gdk_frame_clock_* symbols; the engine falls back to a fixed 60 FPS vsync
waiter.
This is a known, open upstream limitation: flutter/flutter#183703 — "[Proposal] Support high refresh rate (>60) on Linux". Opened 2026-03-14, labelled P2, no assignee, no linked PR, no milestone. Linux is the last Flutter platform without >60 Hz support. Related: #93058 (Windows, since fixed), #49757.
Implication for the "switch technology" question
A rewrite in another language is not warranted by the evidence. The same Flutter framework and the same Dart code reach ~143 fps on this exact machine when built for web and run in Chrome. Only the Linux desktop embedder is capped.
The leading candidate is therefore shipping the desktop app as a Flutter web
build in a Chrome --app window, reusing the UI and domain code.
Validated at 4K on the real display: 142.9 fps vs the desktop embedder's 20.5 fps on identical content. That settles the renderer question.
What remains unproven is todo's own widget tree on web: the probe is a spinner, and web CanvasKit raster plus WASM/JS build costs differ from the desktop's 0.30/1.50 ms. Given desktop build time is only 0.3 ms and the web renderer has 7x the headroom, this is very likely fine — but the proof-of- concept should build the actual app and re-measure before the port is finished.
What is reused is the UI and domain logic. The platform-adapter layer needs web
variants throughout: persistence durability under a browser profile, token
storage, device auth, sync-on-background (Page Visibility API), and the
launch mechanism (local server vs file://).
Port surface (the storage seam is small, because crdt_sync_dart was designed
for it):
LogStoreis deliberatelydart:io-free and documented as web-usable;FileLogPersistenceis already isolated behind thecrdt_sync_io.dartentrypoint.LogPersistenceis a two-method interface (read()/write(String)), so a web implementation over IndexedDB/localStorage is roughly 15 lines.github_client.darthas nodart:ioand usespackage:http; the GitHub REST API sends permissive CORS headers, so sync works from a browser origin.sqlite_crdt/sqflite_common_ffiremain only for the one-time legacy DB migration (note_repository.dart:350), which does not apply on web and can be skipped behindkIsWeb.path_provideris only used to choose the file path forFileLogPersistence; unnecessary on web.shared_preferences,url_launcher,share_plus,file_selector,flutter_secure_storageall have web implementations.
Known costs of the web route (both are security regressions)
- The token loses the OS keystore. Commit
9841aecdeliberately moved the GitHub token off plaintext and into the OS keyring viaflutter_secure_storage. A Chrome--appbuild cannot reach the keyring;flutter_secure_storageon web falls back to WebCrypto-wrappedlocalStorage. That is materially weaker than what is shipping today and partially undoes that commit. - Device-flow auth breaks. GitHub's OAuth device-flow endpoints
(
github.com/login/device/code) do not send CORS headers, so one-tap device auth will not work from a browser origin. The settings screen's paste-a-personal-access-token path would have to become the desktop auth route — which also means a long-lived PAT living in the weaker storage above.
These two stack, and they are the strongest argument against the web route.
Agreed plan (decided 2026-07-20)
The desktop app moves to a Flutter web build launched in Chrome. Decisions taken, so the next session can start from here:
- Auth: simple PAT pasted into
localStorage(viaflutter_secure_storage_web). The local-helper design that would have kept the OS keyring and device-flow auth was considered and rejected for simplicity. Consequences accepted: the token is no longer in the keyring, and the AES-GCM wrapping is obfuscation only (the wrap key sits in the samelocalStorage).- Requires a classic PAT with "No expiration" to be a one-time paste.
- The launcher must pin a fixed port and a fixed
--user-data-dir, and callnavigator.storage.persist(). Any of those changing loses the token:localStorageis keyed by origin and lives in the Chrome profile.
- The Linux desktop build is deleted, not kept as a fallback. (Android is unaffected and remains a normal Flutter mobile build.)
- Launch: a wrapper script serves the web build on a fixed port and opens
Chrome with
--app=http://localhost:<port>. - Done-condition: the real todo app, fullscreen at 4K, sustains >=100 fps
while scrolling the notes list, and sync works against
kuhyx/syncs.
Still to be specified before coding: the WebLogPersistence implementation,
what replaces path_provider/sqflite on web (the legacy sqlite migration path
at note_repository.dart:350 does not apply), packaging/install_arch.sh
changes, and how the desktop entry launches the wrapper.
Alternatives, if the web route is rejected
Ranked by cost. All require re-implementing the UI, and the two non-Dart options
also require a third crdt_sync implementation (it exists today only in Python
and Dart).
- Accept 60 fps. Zero cost. The app is genuinely smooth at 60.
- Qt 6 / QML (C++). Vulkan RHI, real 144 Hz, best NVIDIA/X11 record. Weeks.
- Rust + Slint. Lightweight, but the highest total cost.
Not recommended: Tauri — on Linux it renders via WebKitGTK, which has its own NVIDIA/X11 DMABUF rendering bugs; it trades one renderer problem for another.