explainers.wiki

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.

01

A claim worth checking

the claim, as heard

Did React beat Rust?

I couldn't find anyone stating the claim with numbers for the same agent app. The closest evidence is in section 9.

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?

02

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.

illustration, slowed down 30 times

The vsync metronome

Budget16.7 ms
Work per frame6 ms
On screen60 fps
Each vertical line is a screen refresh. Work that crosses a line (hatched red) misses it: the screen shows the old frame again (✕) and the ball visibly stutters.

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.

03

Five verbs every UI performs

To get a frame on screen, every UI stack does the same five jobs.

the colour key for this page

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 flat Scene of 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.
Tap a verb to see what each stack calls it. The five colours mean the same thing in every diagram below.

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.

04

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.

where the work happens

React in Chromium: four places, heavily cached

Remembered between frames

  • DOM and computed style
  • Layout fragments
  • Display lists
  • Shaped words
  • Pixels, as raster tiles
In the second frame nothing changed but the scroll position, so the main thread does no work at all: the compositor moves tiles it already has. Timings are schematic.

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.

what a change redoes

React: one agent app, one change

Pick a change. Each part of the app lights up with the verbs it had to redo.

A sketch of an agent app: sidebar, transcript, spinner, composer. The five pips on each part are the five verbs. Here it's React in the browser, with a CSS spinner and memoised messages. An illustration of the browser's rules, not a trace.

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.

a real demo, in your browser

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

The button really does freeze this page's main thread with a busy loop. The left strip keeps moving, and you can still scroll the page. The right strip stops dead, standing in for Flutter and GPUI, which run app code on their main thread for every scroll frame.

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.

05

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.

where the work happens

Flutter: two frames, two threads

Remembered between frames

  • Elements and state
  • Layout geometry
  • Recorded pictures
  • Glyph atlas
  • Pixels (no raster cache)
The raster thread draws frame one while Dart is already building frame two: the pipeline is two frames deep. Timings are schematic.

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.

what a change redoes

Flutter: the same app, the same change

Pick a change and compare it with React above.

The same app in Flutter. An illustration of Flutter's rules, not a trace.

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.

06

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.

where the work happens

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
The red slivers are raster: the main thread writes a few batched draw calls and the GPU runs them. Timings are schematic.

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.

what a change redoes

GPUI: the same app, the same change

Pick a change, then switch caching on and try it again.

With caching on, the sidebar, composer and each message are cached views. A dashed green part was replayed from last frame; a red one missed its cache. An illustration of GPUI's rules, not a trace.

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.

07

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.

real numbers

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.

js-framework-benchmark, Chrome 150 on a MacBook Pro M4, published 2026; medians of 15 runs H. The engine's share is the benchmark's measured remainder after script.

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.
08

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.

illustration: a model, not a benchmark

Streaming into one long message

16.7 ms budget (60 Hz) 0160ms per frame 0250 KBmessage size, 500 KB
  • React in the browser
  • GPUI, naive
  • GPUI, with fixes
  • measured in Zed (layout only)

Three copies of one transcript receive the same tokens; each redraws only as fast as its frame cost allows. The layout slope (0.19 ms per KB) comes from Zed #57349 (June 2026: 1.31, 8.88 and 48.7 ms at 5, 50 and 250 KB) and the highlighting slope from Zed PR #63138 (Sept 2026: 6.62 to 2.92 ms at 50 KB). The parsing cost, the spinner-only costs and React's flat line are assumptions that show the mechanism. Beyond 250 KB the model extrapolates.

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.

09

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.

Zed issue #57349 · June 2026 · H

One long message, laid out every frame

48.7 ms → 1.35 ms per frame, 250 KB message

A contributor's profile and lazy prototype in zed#57349: the markdown element “does not cull to the viewport. It's layed out fresh every frame”. The follow-up PR #59776 was closed unmerged in favour of other work.

Code blocks were highlighted again on every frame. Caching that cut the worst frames from about 31 milliseconds to 16.

Zed PR #63138 · merged 9 Sept 2026 · H

Code blocks, re-highlighted every frame

31.2 ms → 16.0 ms 99th-percentile window draw

From zed#63138: the markdown element “re-highlights every code block with tree-sitter on every frame”. Frames over budget fell from 3.9% to 2.0%. Note how the mean hides the problem: it was already 2.2 ms.

Opening one long thread took Zed from 483 megabytes to almost 9 gigabytes. Restoring another one created more than fifty-four thousand entities.

Zed issues #60134, #64883, #51597 · 2026 · H

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)”
Memory: zed#60134 (June 2026). Entities: zed#64883 (Sept 2026). The quote is a Zed engineer in zed#51597 (March 2026), an issue titled for a 100-fold rise in energy use.

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.

PocketJS benchmark · 18 Aug 2026 · M3 Max · H

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”.

From the PocketJS benchmark document (later removed from the project's main branch; this is the archived commit).

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.

Theo Browne, T3 Code · Feb and March 2026 · H

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.

Posts on X, 8 Feb 2026 and 8 March 2026.

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.

Cap · 21 Oct 2026 · M4 Max, release builds · H

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.

Cap's migration write-up. Discord: press coverage.

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.

Zed release notes · October 2026 · H

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”
Zed release notes. The spinner issue, #51597, was still open when this was researched.
10

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.

The scoreboard: usual winner, by metric
MetricUsual winner
Smooth scrolling of long content while the app is busyReact
Idle CPU with something animating (a spinner, a caret)React
Cold start, idle memory, binary size, process countGPUI
Keystroke latency in an editor; GPU-heavy custom drawingGPUI
Frame time while streaming into long messagesWhoever cached
Memory in a very long sessionWhoever virtualised
The research's judgement M, resting on the mechanisms above H. For the last two rows React usually wins by default, because the browser does the caching without being asked. The React wins are really the browser's.

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.

One question, three answers
React (Chromium)Flutter (Impeller, 3.47)GPUI
Persistent UI treeYes. 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 redoesThe 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 frameMain, 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 framesRaster tiles for every composited layer.None under Impeller.Glyph, icon and image atlases only.
ScrollingThe 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 spinnerA 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.
11

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.

12

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-visibility on 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: memo and 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

Flutter and Dart

GPUI and Zed

The claim

Topics

    explainers.wiki · By Cillian Myles · Idiomatic Bytes

    Keys: ← → step, Page Up and Page Down topic, K play or pause, J and L skip, F present.