A Critique of GPUI
Recent trends
Recently, there has been a trend of more desktop GUI apps being built using native Rust GUI frameworks. And there are now even well-known Electron apps getting official rewrites: Discord announced their app is being rewritten using GPUI.¹
Meanwhile, a lot of the LLM-clones being published seem to choose egui.
In this post, I want to explain why I am not sure those frameworks are a good foundation to build on. To put it plainly: I believe both GPUI's and egui's state models are dead ends for complex desktop applications.
Both are impressive projects, and they have their place. Zed genuinely feels fast and nice to use, and I use egui myself for debug interfaces in Bevy games. But I don't think those frameworks should be the default choice for native desktop apps.
I want to make two arguments:
- Every UI architecture ends up needing to know what changed and who depends on it, or performance characteristics will not scale. A framework whose state model cannot say so makes the application say it by hand, at the cost of whatever made that framework's API semantics attractive in the first place.
- Once you admit you want that precision, components that run once, with signals and reactive holes, are the simplest API for it.
Disclosure: I ported GPUI's current Linux renderer to wgpu², I built the benchmark harness used below, and I work on zgui, a signal-based Rust framework that appears in the charts.
The only question that matters
Raph Levien quotes Ron Minsky: "hidden inside of every UI framework is some kind of incrementalization framework."³
A UI is a function of state, and state changes a little at a time. But if that function becomes expensive, you might want to start looking for ways to derive a faster way to update pixels on the screen from an incremental state change. So every framework has to answer one question: when one value changes, what runs?
| Model | What a write runs | Who finds the change |
|---|---|---|
Immediate (egui) |
All UI code, next frame | Nobody |
| Elm (Iced) | All of view, then a diff |
The diff |
| Views (GPUI) | Every view without a cache | You, with notify |
| Signals, re-run (Freya, Compose) | Components that read it | The framework |
Signals, run once (Floem, Solid, zgui) |
Closures that read it | The framework |
I understand why people like the first three. No state to synchronize, one place where state lives, a render function that is plain Rust, no macros or separate compiler. I'm gonna make the argument that these approaches don't scale, and fixing the underlying problem defeats what makes these models simple and easy to reason about.
GPUI: notify is zone.js
GPUI calls itself "a hybrid immediate and retained mode" framework.⁴ It retains state in entities. The UI is defined in render functions, changing state must trigger a rerun. It is ordinary Rust. You can use if, for and local variables, and there aren't any weird wrappers.
State changes require an explicit notification:
self.threads.unread = false; cx.notify;
notify carries the entity id.⁵ Since Rust doesn't have a write barrier on &mut, GPUI cannot see which field changed (or whether anything changed at all).
The Angular team wrote this about zone.js in 2023: it "is only capable of notifying us when something might have happened", and "This approach has not scaled well."⁶ notify is more precise than a zone, since it names an entity, but ultimately it makes the same kind of statement. Below that id, GPUI knows nothing, so it re-renders every view that is not behind a valid .cached() boundary.
And in practice, GPUI often rerenders every view, because .cached() is rarely used by application authors, and imposes certain restrictions on layout semantics.
GPUI has a reputation of being "fast". And while constants might be relatively low, asymptotically, it is leaving a lot on the table. So I ran a few benchmarks, that specifically test how much a sparse state change in a large UI actually costs.
At 3,000 live rows with a cached view per row, changing one label takes 8.4 ms. A hover, a keystroke and writing a value equal to the old one all cost about the same, because the cost follows the tree and not the change. It grows by about 3 ms per 1,000 live rows.
Write the rows as plain elements, as in the code above, and the same label takes 92 ms. A hand-written equality guard takes the equal write from 7.9 ms to 0.9 µs (demonstrating how much is left for each call site to find).
This doesn't mean that every GPUI app is slow, but it shows the limit of the state model. Especially with more expensive layout, you do hit this wall in practice. Zed might feel fast, but the truth is that Zed is a relatively simple app, UI wise. The app's chrome contains a few buttons, in a simple layout, a sidebar and a text editor, and the text editor is implemented as a custom element, completely bypassing GPUI's layout and view semantics. By their own account, a window "has tens of nodes and a few thousand elements".⁷
Making GPUI precise defeats GPUI
The obvious fix is to retain more. Zed has experimental branches for this, and Longbridge's gpui-fast is a fork that keeps every view's subtree between frames.⁸ ⁹ It works, within a limit: the smallest thing either one can reuse is a view. So to update one label without rebuilding the sidebar, the label has to be a view:
Its owner then has to create it and keep the handle:
let badge = cx.new;
That is an entity, a Render impl, a subscription, a stored copy of the value, a hand-written guard and an owner, all to express that one label depends on one count. In a signal framework it is this:
let unread = new; derived
The rest of GPUI's API works against this too. Render takes no props, so parents push values into child entities during their own render. RenderOnce components, which is what buttons and rows are, draw inside their parent's view and are no boundary at all. List rows are closures, so a row view means managing a keyed map of entities by hand. And it only helps where state happens to line up with views.
I reran gpui-fast's own 60-panel benchmark, drew each frame once, and then moved the state: With state owned by each panel, one change saves 83%. With a shared model, retention makes the same frame 11% slower. The window and the edit are identical.
In real Zed, the Zed team measured retention "1-3% slower than off on ordinary workloads", found that "Most of the real-Zed win" came from other work, and warned that "microbenchmark wins didn't predict real-Zed results".⁸ gpui-fast is real progress and costs little to adopt: it takes the 3,000-row label from 8.4 to 3.4 ms. But that frame renders three views and lays out one element, and still spends 3.4 ms, because the frame is still a walk over the whole tree.
The web has already walked this road. Redux's style guide says "Prefer having more UI components subscribed to the Redux store and reading data at a more granular level".¹⁰ MobX says to "create lots of small observer components".¹¹ Both tell you to make a boundary per projection, manually, because the framework can't infer it.
This is the part I find hardest to get past. People choose GPUI because render is a plain function of state. A UI where each projection is a separately owned entity with a subscription and a guard is no longer that. It is a signal graph built by hand from much heavier parts, where a missed edge is silent. Zed's own design note says "Missing notifications are application bugs".¹²
Keep the pleasant API and you get view-sized updates. Take the precision and you lose the pleasant API. A signals crate on top would not change the trade, because its smallest sink is still a view. Reaching one node with no render running needs writes the framework can see and nodes that outlive a frame, and that means a new state model and a new element contract.
Elm: deceptively simple at small scales
At the size it is usually shown, the Elm architecture is a deceptively simple UI model: one state value, one update, one view, and every change has a name. While large applications do use it successfully,¹³ ¹⁴ I do want to make the point that this clarity belongs to the small case, and in a complex application signals and stores are arguably easier to reason about.
Take the same unread count. It has to be right after every message, so either view recomputes it on every update or update maintains it:
match message
The definition of unread is now nowhere in particular. It is spread across every arm that touches a thread, and each new message has to remember it. The compiler checks that the match is exhaustive, but it does not check the arithmetic. The memo above has one definition, and a new writer cannot forget it.
The same thing happens with location. Where does a hover state or a half-typed draft live? Single source of truth says in the model, with a message type routed down and an outcome routed back up. Elm's guide: "Actively trying to make components is a recipe for disaster in Elm."¹⁵ Iced recommends returning an Action to the parent.¹⁶ Iced's maintainer says components in this architecture always end "with pain", and also makes the best case for the discipline: avoiding them "forces you to think hard about your data model".¹⁷
Okay, I guess that's a fair argument. But cosmic-files has 136 top-level message variants and 73 more for a tab, plus a Tab::update that returns commands for the parent to interpret.¹⁸ Everything in that is explicit, and none of it is simple.
The architecture also has the same issue GPUI has. The update implementation tells you what a message writes, it cannot tell you what read the field. Anything inside the view might. Elm's architecture doesn't answer "what changed?", it answers "what happened?". Session export might be useful in some scenarios, but it is not useful for scalable performance characteristics.¹⁹
This is what I read in the web's history. Redux is the best-known relative of this design, and its docs read like a field report. Treat a portion of your store "as if it were a database".²⁰ Selectors "will be re-run after every dispatched action".²¹ The core package is "obsolete", replaced by a toolkit made to remove boilerplate.²²
Outside React, the mainstream has moved to tracked reads. Angular, Vue, Svelte and Solid build on signals or something equivalent, Preact offers them, and Svelte's team put it this way: "we've come to the realisation that Knockout was right all along."²³ Inside React, Jotai and MobX bring tracking in as libraries, and Legend-State even offers a "render once" style.²⁴ Most applications are still on React, and React's team disagrees, which I come back to below. So the convergence is on the state model, outside one large exception.
Performance follows the same line. Since Iced rebuilds all of view after every batch of messages, one label at 3,000 rows builds 39,023 widgets and takes 7.3 ms. Lazy on every row only gets that to 5.7 ms, because view still has to visit each row to hash its key. The official lazy example keys on a version: u8 that four update arms bump by hand.²⁵ That is a dirty flag with a nicer name.
In fairness, cosmic-files and Halloy use no lazy at all, and on a virtualized list Iced is one of the fastest frameworks I measured, at 0.19 ms. A bounded screen rebuilds cheaply. But asymptotic complexity is clear. And similar to GPUI, the API semantics simply can't express the necessary information.
There is actually precedence for this: Vizia shows what happens when you add precision to this model. It moved from lenses to signals in March 2026 and kept its Model and event dispatch.²⁶ This is its counter, shortened:²⁷
let count = new; AppData .build; new .on_press; new.width.live;
The label reads the signal directly, and the same closure that builds the button created it. The enum, the dispatch and the handler exist to carry one call to update. Vizia was never strict Elm, since its views are built once, and signals were a sensible move: it got per-field writes. But look at what remains of Elm's reasons. State is a graph of mutable cells, Model is a bag of handles, and event is a &mut self side effect. You now have two systems for saying that something changed.
So my objection to adding signals to the Elm architecture really is the same as with GPUI: they work, and they remove what you came for.
A quick note on immediate mode
egui's pitch is the best in the field: "You don't have to worry about app state and GUI state being out-of-sync".²⁸ For a tool, this is hard to beat:
for row in rows
The price is that a frame costs the size of the UI, independent of what changed. At 3,000 rows every small edit takes about 32 ms, or about 19 ms with accessibility off. egui's author is candid about the trade: immediate mode is "easier to use, but less powerful".²⁸
As the application grows, the original attraction goes. The layout paradox is "a fundamental shortcoming".²⁸ Long lists need show_rows, which takes one fixed row height. Rerun, the largest egui application I know of, clamps queries by "inspecting egui state from the previous frame".²⁹ Anything expensive goes behind a cache keyed on your data. A cache that has to agree with your data is the synchronization problem immediate mode set out to remove, now in application code, one frame late.
One engine, different wiring
The harness can show this directly, because some frameworks can be wired coarsely and finely on the same engine: I benchmarked some of the engines in two ways, once in the most fine-grained way they can be used, and once in a coarser way. The results show how much there is to gain with fine-grained reactivity.
Two things stand out. Iced's factor is small because the view function still runs. And Floem's coarse case is worth a look: every cell reads one global revision signal, which is notify in signal clothing. It is the slowest result on the chart. So signals do not rescue coarse state.
The React team foun
Comments
No comments yet. Start the discussion.