Performance
Startup budgets, main-loop discipline, rendering, memory, battery, and a profiling workflow with Sysprof, perf, heaptrack, and GTK Inspector.
Performance is not a benchmark score. It is the feeling that the app responds before you finish the thought. It is also measurable, budgetable, and regressable — which means it belongs in engineering, not in a late optimization sprint.
Ubuntu 26.04 ships Sysprof as a default system utility. Use it. A profile of the real app on the oldest hardware you support is more useful than guesswork.
Budgets
Section titled “Budgets”Set budgets before optimizing. A focused desktop app should be able to defend these on a mid-range laptop from the last five years.
| Metric | Target | How to measure |
|---|---|---|
| Cold start to first window | < 1 s (focused app), < 2 s (complex app) | Sysprof, startup marks |
| Time to interactive | < 500 ms after first frame | Manual + profile |
| Input latency | < 50 ms | libinput debug-events, wev, Sysprof timestamps |
| Frame time during scroll | < 16.7 ms (60 fps) | Sysprof, GTK Inspector |
| Idle CPU | < 1% | top, powertop, Sysprof |
| Resident memory | < 200 MB (document app), < 500 MB (complex) | /usr/bin/time -v, heaptrack |
| Idle wakeups | Near zero | powertop, perf |
| Install size | < 20 MB app payload (Flatpak runtime shared) | du -sh |
These are not universal laws. A video editor and a notes app have different budgets. The point is to write the budget down and test it.
Measure before you optimize
Section titled “Measure before you optimize”Sysprof
Section titled “Sysprof”Sysprof records CPU samples, marks, and (with perf integration) kernel events. It is the default on Ubuntu 26.04 and understands GLib/GTK apps.
- Start Sysprof.
- Launch the app and perform the slow action.
- Stop the recording.
- Inspect the call tree, flame graph, and marks.
- Fix the top bottleneck, not the most interesting one.
Add marks around startup phases and long operations so the profile tells a story: “open database”, “load notes”, “first paint”.
perf record -g ./build/notesperf reportperf is excellent for CPU hotspots and scheduler behavior. It needs symbols; build with debug info (-g) for profiling, and strip for release.
heaptrack
Section titled “heaptrack”heaptrack ./build/notesheaptrack_gui heaptrack.notes.*.gzUse it for allocation counts, peak memory, and leaks. A slow app is often an allocation-heavy app.
GTK Inspector
Section titled “GTK Inspector”GTK_DEBUG=interactive ./build/notesInspect the widget tree, CSS, layout, render nodes, and accessibility. Use the Recorder tab for render nodes and clip regions; the Objects tab shows properties, actions, CSS nodes, and accessibility.
Startup tracing
Section titled “Startup tracing”For startup work, capture a trace instead of guessing:
sysprof-cli -- ./build/notesGDK_DEBUG=frame-timings ./build/notesperf may require perf_event_paranoid to be lowered for your user; do that deliberately on a development machine, not on a shared production host.
Other tools
Section titled “Other tools”| Tool | Use |
|---|---|
strace -c |
Syscall overhead, file I/O patterns |
ltrace |
Library calls (rarely needed) |
valgrind --tool=callgrind |
Instruction-level profiles (slow) |
massif |
Heap profiles |
hyperfine |
Microbenchmarks and startup loops |
cargo bloat |
Rust binary size contributors |
powertop |
Idle wakeups and power |
wev |
Wayland input events |
GSK_RENDERER=help |
List GTK renderers |
GDK_DEBUG=help |
List GDK debug options |
Startup
Section titled “Startup”The first second is mostly about what you do before the first frame.
- Create the window and show a usable shell before loading data.
- Defer non-critical work: thumbnails, indexes, plugin discovery, update checks.
- Do not open every database, parse every file, or enumerate the home directory at startup.
- Keep imports/initialization small. Python apps pay for imports; Rust apps pay for static initialization; C apps pay for plugin loading.
- Use
Gtk.Application’sstartupfor global setup andactivatefor the window. - Avoid synchronous network calls; show cached data first.
- Use a skeleton layout that matches the final content to avoid layout shift.
def on_activate(self, _app): window = NotesWindow(application=self) window.present() GLib.idle_add(self.load_secondary_content, priority=GLib.PRIORITY_DEFAULT_IDLE)For a Python app, measure import time:
python3 -X importtime src/main.py 2> importtime.logIf a library costs 300 ms and is only needed for an optional feature, import it lazily.
The main loop
Section titled “The main loop”A responsive app is an app that never blocks the main loop.
- Move file I/O, network, parsing, compression, and database queries off the main thread.
- Use async APIs with cancellation.
- Chunk large work into idle callbacks so input can be processed between chunks.
- Avoid
sleep, synchronoussubprocess, and blocking locks on the main thread. - Do not update the UI once per item in a large loop; batch and coalesce.
def process_batch(self, items, offset=0, batch=100): for item in items[offset:offset + batch]: self.process(item) next_offset = offset + batch if next_offset < len(items): GLib.idle_add(self.process_batch, items, next_offset, batch)Measure input latency, not just CPU time. A fast function that runs 60 times per second on the main thread is still a problem.
Rendering
Section titled “Rendering”GTK 4 renders through GSK, usually on the GPU. Most rendering problems are caused by the widget tree, not the renderer.
- Keep the widget tree shallow. Deep nesting costs layout and paint.
- Use
Gtk.ListViewwith recycled rows for long lists; do not build thousands of widgets. - Avoid CSS selectors that match huge subtrees; keep custom CSS small.
- Do not animate layout properties (width, height, margin); animate transform and opacity.
- Hide or destroy off-screen content; do not keep a full second UI in memory.
- Use
Gtk.Picturewithcan_shrinkand appropriately sized textures for images. - Avoid decoding a 6000×4000 image just to show a 64×64 thumbnail.
- Test with
GSK_RENDERER=gl,ngl,vulkan, andcairoif you support multiple renderers; do not force one in production without a reason. - Use
Gtk.GraphicsOffloadfor fullscreen video or other surfaces that should bypass GSK composition, and testGDK_DEBUG=gl-disableto catch fallback behavior.
If scrolling stutters, check:
- Row count and widget depth.
- Image decoding on the main thread.
- CSS complexity.
- Layout invalidation on every frame.
- A synchronous operation triggered by scroll.
Memory
Section titled “Memory”- GObject is reference-counted; cycles leak. Use weak references in signal handlers and callbacks.
- Rust
Rccycles leak the same way; useWeak. - Python references keep GObject wrappers alive; disconnect signals when destroying widgets.
- Bound caches: thumbnails, decoded images, search results, undo history.
- Release large buffers when a view closes.
- Use
GdkTextureandGtk.Pictureinstead of keepingGdkPixbufcopies. - Use
GMemoryMonitorlow-memory warnings to drop caches and bound memory. The kernel OOM killer sendsSIGKILL; the app cannot catch it or “fail gracefully” after the kill. - Decode untrusted images with Glycin, which also limits resource use.
Battery and background work
Section titled “Battery and background work”- Prefer event-driven APIs over polling. D-Bus signals,
Gio.NetworkMonitor, and file monitors are cheaper than a 1-second timer. - Use the longest acceptable timer interval; coalesce updates.
- Stop animations and timers when the window is not visible.
- Release idle inhibitors when work finishes.
- Use
Gtk.Application.inhibitor the Inhibit portal, and test with power-profiles-daemon in balanced, power-saver, and performance modes. - Do not hold a GPU context or a wake lock for a hidden window.
- Test suspend/resume: the app should reconnect, refresh, and not spin.
- A Wayland compositor restart normally terminates client connections. Persist state continuously and restore it on the next launch; do not promise a live reconnect unless your toolkit and compositor explicitly support it.
powertop and Sysprof can show wakeups and CPU residency. A “0% CPU” app that wakes 100 times per second is still draining the battery.
A profiling workflow
Section titled “A profiling workflow”- Write the budget. “Cold start under 1 s on a 2021 ThinkPad.”
- Reproduce. Use the same hardware, session type, scaling, and data set.
- Capture. Sysprof for CPU and marks; heaptrack for memory; GTK Inspector for layout.
- Find the top cost. Ignore the rest until the top is fixed.
- Fix one thing. Measure again. Do not bundle five changes.
- Add a regression check. A startup benchmark, a frame-time budget, or a memory ceiling in CI.
- Repeat. Performance work is a loop, not a milestone.
CI and release gates
Section titled “CI and release gates”- Track binary/package size on every release.
- Run a startup benchmark on a fixed runner with a
--quit-after-startupflag. - Fail the build on a large regression (for example, > 20% startup time).
- Keep a performance profile attached to release notes for major versions.
- Test on the oldest supported hardware before declaring a release “fast”.
hyperfine --warmup 3 --runs 20 \ './build/notes --quit-after-startup'Implement --quit-after-startup in the app: show the window, wait for the first frame, then exit. It is a test hook, not a user feature.
Common wins
Section titled “Common wins”| Problem | Fix |
|---|---|
| Slow cold start | Defer plugins, indexes, thumbnails; lazy imports |
| UI freezes on open | Async file/network I/O; cancellable tasks |
| Scroll stutter | Gtk.ListView, fewer widgets, smaller images |
| High idle CPU | Remove polling; use D-Bus signals |
| Memory grows over time | Bound caches; break GObject/Rc cycles |
| Slow search | Search the model, debounce, index in a worker |
| Battery drain | Fewer wakeups; stop timers when hidden |
| Slow first paint | Show the shell before loading data |
| Large package | Strip binaries, share runtime, remove unused assets |
Sources
Section titled “Sources”Next: Testing — how to prove the app works before users do.