Skip to content
📖 14 min🎯Difficulty:
Prerequisites:Property bindingsTimer component

The Problem ​

A shell runs constantly — every millisecond of CPU time spent on rendering or bindings is time not available for your applications. A slow shell makes the entire desktop feel sluggish. Unlike a web app that users tolerate loading for 2 seconds, a shell must be instant and stay fast indefinitely.

The Naive Approach ​

Update everything every frame:

qml
Timer {
  interval: 16  // ~60 FPS
  running: true; repeat: true
  onTriggered: {
    cpuPercent.text = readCpuUsage()
    memPercent.text = readMemUsage()
    networkSpeed.text = readNetworkSpeed()
    diskFree.text = readDiskFree()
    batteryPercent.text = readBatteryPercent()
    temperature.text = readTemperature()
    fanSpeed.text = readFanSpeed()
  }
}

This polls every hardware sensor 60 times per second. Even if nothing changed, you're burning CPU re-reading files and re-rendering strings. On a laptop, this drains battery. On a desktop, it steals cycles from your actual work.

💡Mental Model

Think of performance like water conservation. Every property binding is a pipe, every update is a drop of water. You want enough pipes to deliver water when needed, but not so many that they leak constantly. A well-designed shell trickles — it doesn't gush.

The Principles ​

1. Update at the Right Frequency ​

Different data needs different update rates:

DataRecommended IntervalWhy
Clock (seconds)1000 msOne tick per second
Clock (minutes only)60000 msOne tick per minute
CPU usage1000-2000 msSmooth enough for a bar
Memory usage2000-5000 msChanges slowly
Disk usage10000-30000 msAlmost static during a session
Network speed1000-2000 msShows activity, not precision
Battery level30000-60000 msChanges over minutes/hours
Weather600000 ms (10 min)Updates from API
Music track infoOn change via MPRISNo polling needed

2. Stop Updating When Hidden ​

A popup that's not visible shouldn't update its contents:

qml
Timer {
  interval: 1000
  running: popup.visible  // Only runs when popup is open
  repeat: true
}

3. Avoid Expensive Operations in Bindings ​

Bindings re-evaluate whenever their dependencies change. Keep them cheap:

qml
// Bad — regex in a binding
Text { text: someString.replace(/[aeiou]/gi, "").toUpperCase() }

// Better — do it in a service once
Text { text: MyService.transformedString }

4. Use readonly When Possible ​

Marking properties readonly lets the QML engine optimize memory access:

qml
readonly property string formattedTime: Qt.formatDateTime(clock.date, "hh:mm")

5. Lazy-Load Non-Essential UI ​

Not every widget needs to load at startup. Use LazyLoader for sections that aren't immediately visible:

qml
LazyLoader {
  source: "WeatherWidget.qml"
  active: weatherEnabled
}

The component is created only when active becomes true.

Profiling Your Shell ​

To find performance issues, enable the QML profiler:

bash
export QML_DEBUG=1
quickshell

Or use QML_LOGGING=1 to see binding evaluation counts. Look for bindings that evaluate hundreds of times per second — they're your hotspots.

Common Bottlenecks ​

ProblemSymptomFix
Polling timer in hidden popupCPU spike on desktop idleCheck visible before updating
Complex binding in delegateLag when scrollingPrecompute values, use Loader
Image decoding on loadUI freeze at startupSet asynchronous: true on Image
Frequent Qt.process() callsShell feels jitteryBatch calls, cache results
No clip: true on ListViewVisual artifacts, overdrawAdd clip: true
⚠️Common Mistake

Optimizing too early. Before profiling, you don't know what's slow. Write clean code first, profile to find the 1-2 hotspots, and optimize those. Premature optimization of non-hot paths adds complexity without benefit.

Ignoring timer accumulation. If a timer fires every 16ms and the callback takes 20ms, you'll have 2 overlapping callbacks. Use Timer { running: true; repeat: true; triggeredOnStart: false } and check that you're not queuing work faster than you can process it.

Under the Hood ​

The QML engine batches binding evaluations. When a property changes, the engine doesn't immediately re-evaluate every dependent binding. Instead, it marks them as "dirty" and evaluates them in a cleanup pass. This means changing three properties that all affect the same binding costs one evaluation, not three.

Understanding this batching helps you avoid premature optimization — changing a property once vs. three times doesn't matter much if they're all in the same event handler.

Exercises ​

⭐Exercise
Profile your current shell. Add a `console.profile("shell")` / `console.profileEnd("shell")` pair around a 5-second window. Identify the three most expensive bindings and optimize them.
⭐⭐Exercise
Replace all 16ms timers with appropriate intervals based on the data they fetch. Implement visibility-based stopping for popup timers. Measure CPU usage before and after.
⭐⭐⭐Exercise
Build a performance dashboard widget that shows: FPS, binding count, timer count, and estimated power draw. Use it while testing your shell under different conditions (idle, heavy load, with popups open).
✅What You've Learned
  • Match update frequency to data volatility
  • Stop timers when popups are hidden to save CPU and battery
  • Avoid expensive operations in property bindings
  • Use LazyLoader for non-essential UI, profile before optimizing

Built with VitePress. Licensed under MIT.