Back to blog
· 8 min read · #Rust#performance#guide

Performance optimization for Rust desktop applications

Optimizing performance in Rust desktop apps: render batching, lazy loading, memory budgets, and profiling techniques.

hmziqrs
hmziqrs @hmziqrs

Rust desktop apps have a reputation for being fast. The language eliminates garbage collection pauses, has minimal runtime overhead, and compiles to native code. But “fast language” does not automatically mean “fast application.” I have shipped Rust GUI apps that compiled fine and still felt sluggish because I was doing dumb things in the render loop.

The gap between “Rust fast” and “app feels fast” is where most desktop performance work happens. Here is what I focus on when optimizing Rust desktop apps, with concrete code patterns and the tradeoffs I have run into.

Render batching and minimizing draw calls

Every frame, your UI framework has to figure out what changed and send it to the GPU. The fewer distinct draw operations, the faster the frame. GPUI handles a lot of this automatically under the hood, but you can still cause unnecessary work if your element tree is structured poorly.

Elements that share the same visual properties (background color, border radius, text style) can be batched into a single draw call. Elements that break the batch force a new one.

// Bad: alternating styles force separate draw calls for each item
fn render_list(items: &[Item]) -> impl IntoElement {
div().children(items.iter().enumerate().map(|(i, item)| {
let bg = if i % 2 == 0 { blue() } else { gray() };
div().bg(bg).child(item.label.clone())
}))
}
// Better: uniform style, batch into one draw call
fn render_list(items: &[Item]) -> impl IntoElement {
div().children(items.iter().map(|item| {
div().bg(blue()).child(item.label.clone())
}))
}

Yes, zebra striping is nice. But if your list has 500 items and each one forces a style change, that is 500 draw call boundaries. On a 120Hz display where you have 8ms per frame, the GPU cost adds up. I usually pick uniform styling for long lists and save alternating backgrounds for tables with under 50 rows.

GPUI’s rendering pipeline batches elements that share styles automatically. Your job is to avoid breaking those batches with unnecessary per-element variation.

Lazy loading for large data sets

A common pattern in desktop apps: the user opens a project, and you load everything into memory at once. File trees, git history, search indexes, all of it. This makes the initial startup slow and keeps memory high even when the user only interacts with a fraction of the data.

Lazy loading fixes this by deferring work until it is needed. In a file tree, you load the root directory and expand children on demand. In a search results view, you load the first 50 results and fetch more when the user scrolls near the bottom.

pub struct LazyList<T> {
loaded: Vec<T>,
total_count: usize,
page_size: usize,
loading: bool,
}
impl<T> LazyList<T> {
pub fn new(total_count: usize, page_size: usize) -> Self {
Self {
loaded: Vec::with_capacity(page_size),
total_count,
page_size,
loading: false,
}
}
pub fn needs_more(&self, visible_end: usize) -> bool {
!self.loading
&& visible_end + self.page_size > self.loaded.len()
&& self.loaded.len() < self.total_count
}
pub fn append(&mut self, items: Vec<T>) {
self.loaded.extend(items);
self.loading = false;
}
pub fn visible_slice(&self, start: usize, end: usize) -> &[T] {
let end = end.min(self.loaded.len());
let start = start.min(end);
&self.loaded[start..end]
}
}

The tradeoff is latency when scrolling fast. The user hits the bottom of loaded data and there is a brief pause while the next page fetches. I mitigate this by triggering the load when the user is within 2-3 viewport heights of the end, not when they actually reach it. Pre-fetching one page ahead is cheap and eliminates the visible stutter.

For state management patterns that work well with lazy loading, keep loaded and unloaded state separate so your render function does not need to handle missing data conditionally.

Memory budgets and allocation tracking

Desktop apps run for a long time. A server gets restarted every few hours. A desktop app might run for weeks. This makes memory growth a bigger problem than it is in shorter-lived processes.

I set explicit memory budgets for subsystems. The file tree gets 10MB. The search index gets 50MB. The undo history gets 20MB. When a subsystem exceeds its budget, I either evict old data or refuse to grow. Hard limits prevent slow unbounded growth from turning into a swap-stalling mess.

pub struct BoundedCache<K, V> {
entries: LinkedHashMap<K, V>,
max_size: usize,
current_size: usize,
}
impl<K: Hash + Eq, V: Measurable> BoundedCache<K, V> {
pub fn insert(&mut self, key: K, value: V) {
let size = value.size_bytes();
// Evict oldest entries until we have room
while self.current_size + size > self.max_size && !self.entries.is_empty() {
if let Some((_, old)) = self.entries.pop_front() {
self.current_size -= old.size_bytes();
}
}
if size <= self.max_size {
self.current_size += size;
self.entries.insert(key, value);
}
// If a single entry exceeds the budget, we skip it
}
pub fn get(&mut self, key: &K) -> Option<&V> {
self.entries.get(key)
}
pub fn current_usage(&self) -> usize {
self.current_size
}
}
pub trait Measurable {
fn size_bytes(&self) -> usize;
}

The LinkedHashMap gives us O(1) insertion and O(1) eviction of the oldest entry. When the budget is exceeded, we drop the least recently used data first. This is a cache, so dropping old entries is expected behavior, not data loss.

I also track allocation rates during development. If a subsystem is allocating 100MB per minute of sustained use, something is wrong even if the peak memory looks fine. High allocation rates mean the allocator is doing work, and that work shows up in CPU profiles.

Avoiding allocations in the render path

The render function runs 60 to 120 times per second. Any allocation inside it is an allocation that happens 60 to 120 times per second. String formatting is the worst offender because it is so easy to write.

// Allocates a new String on every frame
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let status = format!("{} items loaded", self.count);
div().child(status)
}
// Better: only format when the count actually changes
struct CachedLabel {
last_count: usize,
cached: String,
}
impl CachedLabel {
fn label(&mut self, count: usize) -> &str {
if count != self.last_count {
self.cached = format!("{} items loaded", count);
self.last_count = count;
}
&self.cached
}
}

Checking whether a value changed before doing work applies everywhere in GUI rendering. GPUI element rendering already does this internally for style properties. You should apply the same discipline to your own data.

Another allocation trap: collecting iterators into Vec inside render. If you need a list of elements, use iterators directly with .children() instead of collecting first.

// Bad: collects into Vec, then passes to children
let items: Vec<Div> = data.iter().map(|d| div().child(d.name.clone())).collect();
div().children(items)
// Better: iterator passed directly
div().children(data.iter().map(|d| div().child(d.name.clone())))

The second version avoids the intermediate allocation. GPUI consumes the iterator without materializing the full vector.

Async work off the main thread

This should be obvious but I keep seeing it in code reviews: synchronous work on the main thread that should be async. File I/O, database queries, network requests, heavy computation. Any of these will block rendering and cause frame drops.

// Bad: blocks rendering
fn load_project(&mut self, path: &Path, cx: &mut ViewContext<Self>) {
let data = std::fs::read(path).unwrap(); // can take 50ms+
self.project = parse_project(&data);
cx.notify();
}
// Good: async, non-blocking
fn load_project(&mut self, path: PathBuf, cx: &mut ViewContext<Self>) {
cx.spawn(|this, mut cx| async move {
let data = tokio::fs::read(&path).await.unwrap();
let project = parse_project(&data);
this.update(&mut cx, |this, cx| {
this.project = Some(project);
cx.notify();
});
}).detach();
}

The async version lets the render loop continue while the file loads. When the data is ready, the callback updates state and triggers a re-render. The user sees a loading state instead of a frozen window.

For patterns on managing async state at scale, the profiling guide covers how to measure whether your async work is actually staying off the main thread.

Debouncing and throttling user input

Text inputs fire events on every keystroke. Scroll handlers fire on every frame. Search-as-you-type queries the database on every character. Without throttling, you end up doing expensive work for intermediate states that the user never sees.

use std::time::{Duration, Instant};
pub struct Debounce {
last_fire: Instant,
min_interval: Duration,
pending: Option<String>,
}
impl Debounce {
pub fn new(interval_ms: u64) -> Self {
Self {
last_fire: Instant::now(),
min_interval: Duration::from_millis(interval_ms),
pending: None,
}
}
pub fn input(&mut self, value: String) -> Option<&str> {
self.pending = Some(value);
if self.last_fire.elapsed() >= self.min_interval {
self.last_fire = Instant::now();
self.pending.take().as_deref()
// not enough time has passed
} else {
None
}
}
}

I debounce search inputs at 150ms. The user types “rust desktop,” and instead of firing 12 queries (one per keystroke), the app fires one query 150ms after the last keystroke. For scroll handlers I use throttling instead: fire at most once every 16ms, which aligns with the frame rate.

The tradeoff with debouncing is perceived latency. A 150ms debounce means the user waits 150ms after they stop typing before seeing results. For search, this is fine. For autocomplete in a code editor, 50ms feels better. Pick the number that matches the interaction.

Measuring what matters

Before optimizing anything, measure. I wrote about the profiling workflow I use in detail elsewhere. The short version:

  1. Add a frame time overlay to your app during development. Track average and p99.
  2. If p99 is under 16.67ms, stop. You are fast enough.
  3. If p99 is over budget, reproduce the jank and profile with Instruments (macOS) or perf (Linux).
  4. Fix the hottest function. It is almost always one of: excessive re-renders, allocations in the render path, or blocking I/O.
  5. Measure again. Repeat until p99 stays under budget.

Premature optimization wastes time. Optimizing the wrong thing wastes even more time. Measure first.

Putting it together

Render batching, lazy loading, memory budgets, allocation avoidance, async offloading, and input debouncing apply to any Rust GUI stack. The code examples use GPUI because that is what I work with, but the principles transfer to Tauri, Slint, Iced, or anything else.

If you want to see these patterns applied in a real project, gpui-starter includes lazy-loaded navigation, bounded caches for theme data, debounced search in the command palette, and a frame time debugger you can toggle during development. The getting started guide walks through the setup. The architecture docs explain where each optimization lives and why.

Performance work is iterative. Ship something that works, measure it, find the slow part, fix it, measure again. Rust gives you the ceiling. Your job is to write code that actually reaches it.