GPUI vs React vs Flutter
How Zed's Rust framework draws a frame, how that compares with React and Flutter, and whether React really could beat it.
A voice reads the page, and the words light up as they're spoken. The diagrams follow along. Or just read it and play with everything. Present goes full screen and plays the whole thing like a video.
A claim worth checking
Did React beat Rust?
Someone told me a React app ran better than the same app built with GPUI. GPUI is the Rust framework behind the Zed editor. The app was an agent harness, the kind where a model streams text into a long transcript.
My first reaction was, that can't be right. JavaScript is a scripting language running inside a browser. Rust compiles to machine code and has no garbage collector. How would the browser win?
Turns out it can. For this kind of app, it sometimes has. And it's got nothing to do with JavaScript being faster than Rust. To see why, I'm going to ask one question of three stacks: React, Flutter and GPUI.
Where does each frame's work happen, and how much of the last frame is remembered?
A frame is a budget
Your screen redraws at a fixed beat. At 60 hertz, there's a new frame every 16.7 milliseconds. At 120 hertz, it's every 8.3.
The vsync metronome
All the work for a frame has to fit inside that beat. If it fits, motion looks smooth.
If it runs long, the screen has nothing new to show. So it shows the old frame again, and you see a stutter. That's a dropped frame.
Faster screens are less forgiving. Work that was fine at 60 hertz can miss every second beat at 120.
The rest of this page is really about fitting inside that bar.
Five verbs every UI performs
To get a frame on screen, every UI stack does the same five jobs.
Describe, lay out, paint, raster, composite
Your code says what the interface should be, right now.
- React
- Components return elements. React diffs them and patches only the DOM that changed.
- Flutter
build()returns widgets, reconciled into persistent elements.- GPUI
render()returns elements, allocated fresh for each frame.
Work out the size and position of every box.
- React
- The browser's LayoutNG. Results are cached and reused when constraints match.
- Flutter
- Constraints go down, sizes come up. Cached on each render object.
- GPUI
- Taffy (CSS flexbox and grid), rebuilt from scratch every frame.
Record a list of drawing commands. No pixels yet.
- React
- The browser records display items in paint chunks, reused when nothing changed.
- Flutter
paint()records a display list per repaint boundary.- GPUI
paint()fills a flatSceneof about 8 kinds of primitive.
Turn the commands into pixels, usually on the GPU.
- React
- The browser's separate GPU process rasters tiles, and keeps them between frames.
- Flutter
- The raster thread: Impeller, on Metal or Vulkan.
- GPUI
- The main thread encodes a few instanced draw calls, and the GPU runs them.
Put the finished pieces together on the screen.
- React
- The browser's own compositor thread, which can scroll and animate by itself.
- Flutter
- The operating system's compositor.
- GPUI
- The operating system's compositor.
First, describe. Your code says what the UI should look like right now. In React, that's your components. In Flutter, it's build. In GPUI, it's render.
Second, lay out. Work out the size and position of every box.
Third, paint. No pixels yet, just a list of drawing commands.
Fourth, raster. Turn those commands into pixels, usually on the GPU.
Fifth, composite. Put the finished pieces together on screen.
Each verb keeps its colour for the rest of the page. And really, the stacks only differ in two ways: which thread does each verb, and what's kept from the frame before.
React, and the browser it runs in
Let's start with React. React itself only does the first verb, describe. Everything after that is the browser's job. And the browser is a huge native rendering engine with about 25 years of tuning behind it. Your JavaScript is a guest in there. Electron is the same engine, so a React desktop app gets all of this too.
When state changes, React runs that component again, and its children too, unless they're memoised. That part is a lot like GPUI. The difference is what happens next. React only touches the DOM nodes that actually changed, and the browser only redoes the work those changes caused.
And the browser remembers nearly everything. The DOM, computed styles, layout and recorded paint. It even keeps the pixels, as tiles on the GPU. Flutter and GPUI don't do that.
React in Chromium: four places, heavily cached
Remembered between frames
- DOM and computed style
- Layout fragments
- Display lists
- Shaped words
- Pixels, as raster tiles
The main thread runs your JavaScript, then style, layout and paint. Each of those is incremental, so if one line of text changes, the engine only redoes that line.
Then there's the bit Flutter and GPUI have nothing like. A second thread, the compositor, owns scrolling and simple animations. It can make new frames from the saved tiles without asking the main thread at all.
And raster happens somewhere else again, in a separate GPU process.
React: one agent app, one change
Pick a change. Each part of the app lights up with the verbs it had to redo.
So say the spinner ticks. If it's a CSS animation that only rotates, it costs your code nothing. The compositor turns it.
A new token dirties one paragraph. React re-renders that one message, the browser reuses its neighbours, and only the tiles under the change get drawn again.
You can actually feel the difference on this page. This demo is real. Both strips scroll. One is moved by the compositor. The other is moved by code on the main thread, which is how Flutter and GPUI make every frame.
Block the main thread. Now scroll.
Compositor thread
A CSS transform. How a browser scrolls and spins.Main thread
Moved by code each frame. How Flutter and GPUI scroll.Main thread: free
Now I'm going to block the main thread. The compositor's strip keeps gliding. The other one freezes, and so does the highlight on these words.
That's the big structural difference. In Flutter and GPUI, which are up next, your code runs on every scroll frame. In the browser, it doesn't.
Flutter: three trees and a raster thread
Next up, Flutter. Flutter keeps three trees. Widgets get thrown away and rebuilt, a bit like React elements. Elements and render objects stick around from frame to frame.
Flutter: two frames, two threads
Remembered between frames
- Elements and state
- Layout geometry
- Recorded pictures
- Glyph atlas
- Pixels (no raster cache)
Dart does describe, lay out and paint on one thread. And this is the first thing that's changed recently. Since 2025, that thread is the platform's main thread. The separate UI thread is gone, on every platform.
The recorded pictures then go over to the raster thread, which turns them into pixels. The two threads overlap, so Dart can start the next frame while the last one is still being drawn.
The second change. Impeller is now the default renderer everywhere except the web, and Impeller has no raster cache. So Flutter remembers its trees and its recorded pictures, but it doesn't remember pixels. They're drawn again on every frame.
Flutter: the same app, the same change
Pick a change and compare it with React above.
So what does a change cost here? Say the spinner ticks. Only the dirty element rebuilds. Layout stops at the nearest relayout boundary, and paint stops at the nearest repaint boundary.
Now a new token lands in a message. That message rebuilds and gets laid out again. Its neighbours aren't touched.
So Flutter sits in the middle. It remembers structure, and redoes pixels.
GPUI: an editor engine that rebuilds every frame
Finally, GPUI. It was built for one job: a code editor that never drops a frame. And on purpose, it's a lot smaller than the browser or Flutter.
State lives in entities, owned by one central app object. A view is just an entity that knows how to render itself. Think of a React component and its state rolled into one, or a Flutter stateful widget and its state.
There's no DOM, no element tree and no render object tree to keep. On every frame that draws, GPUI calls render, builds a fresh tree of elements, lays it out from scratch, paints it, and throws the tree away.
GPUI: everything on the main thread
Remembered between frames
- Entities (your state)
- Shaped text lines
- Glyph, icon and image atlas
- Last frame's shapes, for cached views only
- Element tree
- Layout
- Pixels
And it all happens on the main thread. Describe, lay out, paint, and even writing the commands for the GPU.
That sounds reckless. But the last step is tiny. GPUI only draws about eight kinds of shape, with hand-written shaders, in a few batched draw calls. One of Zed's engineers says drawing a frame takes less than a millisecond. Most of the time goes on building the tree.
So what's remembered? Your state, shaped lines of text, and an atlas of glyphs and icons. Not much else.
GPUI: the same app, the same change
Pick a change, then switch caching on and try it again.
Now the cost. Notify is GPUI's version of set state. It marks the view dirty, and every one of its ancestors. Then the window redraws from the root, and every view renders again.
The escape hatch is the cached view. It's the closest thing GPUI has to React's memo. Wrap a view in it, and GPUI skips render, layout and paint for that view, and replays its shapes from the last frame.
But the cache is fragile. It needs a definite size, and it misses if the view moves. Watch a token wrap onto a new line. Every message above it shifts, and every one of them misses.
Scrolling's the same story. A scroll is a notify, so every scroll frame runs your code on the main thread.
For an editor, that bet pays off. Zed's editor lays out only the visible lines, by hand. For other apps, the caching is on you.
What the language changes, and what it doesn't
Surely Rust still wins on raw speed, though? Often, yes. JavaScript starts in an interpreter and warms up, every time the app launches. Dart is compiled ahead of time for release, with a garbage collector tuned for short-lived widgets. Rust is compiled ahead of time and has no garbage collector at all.
Where a browser frame's time goes
- script (your JavaScript and the framework)
- engine: style, layout and paint
Plain JavaScript: 1.8 ms of script, 18.1 ms of engine work.
But look at where a browser frame's time actually goes. In a standard benchmark, creating a thousand table rows takes about 20 milliseconds in plain JavaScript. Less than two of those are script.
The other eighteen are the engine's own style, layout and paint, and that's native code. With React, the script takes about six.
React does fall behind when it has a lot to diff. Ten thousand rows take it twice as long as plain JavaScript.
So the language is rarely the bottleneck. The caches are. Electron's real cost is somewhere else entirely: memory and start-up time.
JavaScript
V8 tiers up from an interpreter on each launch. Generational collector.Dart
Just-in-time in debug, ahead of time in release. Generational collector.Rust
Compiled ahead of time. No garbage collector. Never benchmark a debug build.The stress test: an agent transcript
Now let's put this under the load from the claim. An agent transcript is a strange one. Text streams in token by token. Single messages get very long. A spinner turns for minutes, and sessions run for hours.
This model is an illustration. It isn't a benchmark. But its slopes come from measurements in Zed's own issue tracker.
Streaming into one long message
- React in the browser
- GPUI, naive
- GPUI, with fixes
- measured in Zed (layout only)
Watch the message grow. React's cost stays flat, because the browser's work follows the size of the change. Naive GPUI climbs, because it lays out the whole message on every frame.
And the spinner makes it worse. It forces a redraw sixty times a second, so GPUI pays the full cost even when no text arrives.
Now let's apply the fixes. First, virtualise inside the message, so only the blocks on screen get laid out.
Second, cache the finished blocks, and only parse the one that's still streaming.
Third, cache syntax highlighting, instead of running it on every frame.
Fourth, put the spinner in its own small view.
With all four, GPUI is flat and fast. None of that needed a different language. It needed the caches the browser gives React for free.
The evidence
Those slopes aren't made up. In 2026, Zed's own agent panel, built on GPUI, paid this bill in public.
One long message was laid out in full on every frame. At 250 kilobytes, a frame took 48.7 milliseconds. A prototype that only laid out what was visible took 1.35.
One long message, laid out every frame
48.7 ms → 1.35 ms per frame, 250 KB message
Code blocks were highlighted again on every frame. Caching that cut the worst frames from about 31 milliseconds to 16.
Code blocks, re-highlighted every frame
31.2 ms → 16.0 ms 99th-percentile window draw
Opening one long thread took Zed from 483 megabytes to almost 9 gigabytes. Restoring another one created more than fifty-four thousand entities.
Every entry built, whether visible or not
483 MB → 8.96 GB after opening one long thread
54,436 entities for one restored thread, against 455 for an empty one; 30 to 60 s of input lag
“when the agent is generating we re-render constantly (due to the spinner)”
One benchmark did put a GPUI app up against web editors doing the same job. GPUI won on start-up and memory, and lost on processor use while typing fast. But that app ran its logic in a JavaScript interpreter, and the author traced the cost to the interpreter. GPUI's drawing wasn't the problem.
A markdown editor: Electron, Tauri, GPUI
Cold start, ms (lower is better)
Idle memory, MB
CPU in a typing storm, %
CPU when idle, %
Caveats. A markdown editor, not an agent app. The GPUI side ran its app logic in QuickJS, a JavaScript interpreter, and the author profiled the typing cost to that, “not rendering”. Electron idles lower “because its caret blinks in the compositor”.
The best-known story is Theo Browne's. He rebuilt a native agent app in Electron to get better performance. But his native version used Apple's AppKit. It wasn't GPUI.
The likely origin of the claim
“I built a codex clone in native code and ported to electron to GAIN perf because text rendering is so hard to get right. Never could make scroll work nice in appkit.”
“I evaluated every option, and Electron had the best performance across all platforms for most things.”
Caveat. The native attempt was AppKit, not GPUI. “Every option” is not itemised, and the research found no post where he names GPUI.
And there's evidence the other way too. Cap's video editor used a third of the processor, and less than half the memory, on GPUI compared with Electron. Discord is reportedly rewriting its desktop app on GPUI as well.
The counter-example: a video editor
CPU during playback, %
Memory, MiB
Caveats. A video editor, not an agent app, and Cap notes its Electron build got optimisations the Tauri one never did. Discord's rewrite is known only through press reports of its CTO's posts on 4 Nov 2026 M.
Zed has shipped fixes since. In October 2026 it started loading diffs and parsing messages only when they're first viewed. So the older numbers might already be out of date.
The bill is being paid down
1.24.1, 7 Oct: “Improved memory and CPU usage while the agent responds in long threads”
1.27.0, 28 Oct: “Improved Agent Panel memory usage by loading tool-call diffs and parsing thread message Markdown only when first viewed”
The verdict
So, is the claim possible? Yes. Is it plausible for one app, one version and one metric? Yes. Does it mean GPUI is a slower way to draw? No.
| Metric | Usual winner |
|---|---|
| Smooth scrolling of long content while the app is busy | React |
| Idle CPU with something animating (a spinner, a caret) | React |
| Cold start, idle memory, binary size, process count | GPUI |
| Keystroke latency in an editor; GPU-heavy custom drawing | GPUI |
| Frame time while streaming into long messages | Whoever cached |
| Memory in a very long session | Whoever virtualised |
It depends what you measure. React in the browser usually wins smooth scrolling while the app is busy, and idle processor use when something's animating.
GPUI usually wins cold start, idle memory, binary size, and keystroke latency in an editor.
And for frame time while streaming, or memory in a really long session, the winner is whoever did the caching work. The browser just does more of it by default.
So, React and the browser remember almost everything. Flutter remembers trees and pictures, and splits raster off. GPUI remembers the least, on one thread.
| React (Chromium) | Flutter (Impeller, 3.47) | GPUI | |
|---|---|---|---|
| Persistent UI tree | Yes. React's fibers, plus the DOM, styles, layout fragments and display lists. | Yes. Element and render object trees. | No. State lives in entities; the element tree is rebuilt each drawn frame. |
| A state change redoes | The component and its children, unless memoised; then only the DOM that changed, down to the affected raster tiles. | Dirty elements; layout to the relayout boundary; paint to the repaint boundary. | The view and all its ancestors; every view that is not cached; all layout. |
| Threads that make a frame | Main, compositor, raster workers and a GPU process. | Main thread (Dart) plus a raster thread, two frames deep. | Main thread only, plus the GPU. |
| Pixels kept between frames | Raster tiles for every composited layer. | None under Impeller. | Glyph, icon and image atlases only. |
| Scrolling | The compositor moves tiles; the main thread is not involved. | Dart runs every frame, but unchanged items keep their pictures. | A notify: render, layout and paint on the main thread. |
| A spinner | A CSS animation runs on the compositor; almost no main-thread cost. | Each tick rebuilds the dirty subtree; a repaint boundary contains it. | Each tick re-renders the view and its ancestors. |
How to judge the next claim
Next time someone tells you one framework beat another, ask a few questions.
Tick what a claim answers. 0 of 7: an anecdote.
A fair test replays the same recorded session into both apps, at a fixed rate, and publishes all of the above.
What to take back to your app
This isn't really about GPUI. The same bill shows up in React and Flutter apps too, just in different places. In React, a big re-render or a scroll listener puts the main thread back in the way. In Flutter, scrolling runs your code and Impeller redraws every pixel.
- Virtualise inside long items, not just the list. Split a long message into blocks. React: a virtual list, or
content-visibilityon top of the browser's layout cache. Flutter: slivers or a builder per block. GPUI: cull to the viewport inside the item. - Cache the finished blocks, and only parse the one that's still streaming. React:
memoand the libraries already do this. In Flutter and GPUI you build it yourself. - Batch tokens into one update per frame, at most.
- Keep the spinner on its own, so it can't redraw everything else. React: a CSS animation. Flutter: its own small widget and repaint boundary. GPUI: a tiny cached view.
- Measure release builds, and look at the slow frames, not the average.
Ask that one question of any stack and the rest follows. Where does each frame's work happen, and how much of the last frame is remembered?
Sources
All read on 9 November 2026. GPUI facts come from Zed's source at commit f0d896f2; Flutter facts describe 3.47 and Dart 3.13; React facts describe React 19. The Zed against VS Code start-up and memory figures that circulate on blogs are left out: they have no published method.
React, browsers and Electron
- Chromium RenderingNG architecture and LayoutNG
- Compositor thread architecture
- js-framework-benchmark, Chrome 150 (2026)
- Electron process model
- VS Code on bracket pairs (2021) and its terminal renderer (2017)
Flutter and Dart
- Inside Flutter and the architectural overview
- Performance best practices and Impeller
- Merged threads on macOS and Windows, on Linux, and the 3.29 and 3.47 release posts
- Impeller surface source (no raster cache) and animator.cc (pipeline depth)
GPUI and Zed
- GPUI source, commit f0d896f2
- Leveraging Rust and the GPU to render user interfaces at 120 FPS (2023)
- Ownership and data flow in GPUI (2024)
- PodRocket with Mikayla Maki (May 2026)
- Zed issues and pull requests: #57349, #59776, #63138, #51597, #60134, #64883
- Zed release notes (Oct 2026)
- A Critique of GPUI (Nov 2026, secondary)
The claim
- Theo Browne on X: 8 Feb 2026, 8 March 2026
- PocketJS: gpui vs Tauri vs Electron (Aug 2026)
- Cap: migrating to GPUI (Oct 2026)
- Press coverage of Discord's rewrite (Nov 2026, secondary)