egui + eframe on the Web: An Honest Post-Mortem
· updated MAR 2026

egui + eframe on the Web: An Honest Post-Mortem

I wanted to learn how to write Rust for the browser. Not a hello-world — I wanted to actually ship a real Rust GUI to the web and find out what that takes. While I was poking around for how people do this, I kept running into egui: an immediate-mode GUI library that compiles to WebAssembly and calls itself, in its own README, “the simplest way to make a web app in Rust.” That was the experiment I wanted to run.

I can’t judge a UI toolkit by reading its docs, so I needed to build something real with it. I picked a pathfinding visualizer — a grid, a few search algorithms, live animation, mouse interaction to draw walls. Enough moving parts to lean on the whole pipeline: rendering, input, layout, and the Rust-to-browser boundary. It’s live at /labs/pathfinding-viz; go play with it before you read what I concluded.

This is me writing down what that build was actually like, not a tutorial. If you’re deciding whether egui + eframe belongs in your own web project, here’s what it cost me.

I first built this in early 2023 on egui 0.21 — what was current that spring. I brought the dependencies and this writeup up to egui/eframe 0.33.x in 2026, so the version-specific notes below (and the API churn) come from that update.

Getting it into the browser was the easy part

This surprised me. I’d braced for the Rust-to-web boundary to be the hard part, and it just wasn’t. The whole lab is one small Rust crate, and wasm-pack turns it into a .wasm binary plus a bit of JavaScript glue. The one sharp edge I caught: wasm-pack resolves its output directory relative to the crate, not my shell, so it scattered files somewhere I didn’t expect — after that I just handed it absolute paths and moved on.

What made it painless was the web target, which emits a plain ES module the browser imports directly. Vite didn’t need any special configuration. A tiny React island imports the glue, kicks off the fetch-and-compile of the binary, and hands eframe the canvas. From that moment eframe owns the canvas, and neither React nor Astro ever touches it again. The whole integration is a handful of lines — they’re in the repo if you want them, but pasting them here wouldn’t tell you anything the last sentence didn’t.

Coming back in 2026 turned up one bit of trivia worth passing on: the rustwasm GitHub org was sunset in mid-2025. wasm-pack lives on under an independent maintainer, but the canonical docs URL I’d bookmarked is dead now. If you go looking, link the maintained book, not the tutorial you remember.

So the part I’d worried about delivered exactly what it promised. All the friction was everywhere else.

Immediate mode is the right shape for this

egui is immediate-mode: no retained widget tree, no callbacks. Every frame, eframe asks you to describe the entire UI from scratch. For a search visualizer that’s not overhead — it’s the whole idea. The render loop and the simulation loop become the same loop. Each frame I advance the search a few steps, then paint every cell exactly as it stands at that instant. Nothing to invalidate, no event plumbing between “the search moved” and “the screen changed.” The speed slider just decides how many steps happen per frame.

Screenshot of the pathfinding visualizer after a run: a green start node on the left, a coral goal node on the right, an indigo band of visited cells, and a warm-white path connecting start to goal, beside a control panel showing the algorithm picker, speed, and a Path found status.

The lab after A* finishes: the indigo wash is the visited set, the warm-white line is the path. Every cell you see is redrawn from scratch each tick — the render loop is the search loop.

The part I genuinely enjoyed was the search code underneath. One small state struct serves all four algorithms, the compiler makes me handle every case, and nothing mutates behind my back. The same thing in JavaScript would have been looser and easier to get subtly wrong, and I’d have trusted it less. If I stopped writing here, this would read like a love letter to Rust on the web.

I’m not going to stop here.

The canvas sizing trap

This is the bug that ate a day of my life. On a 2x display, after I “fixed” what I thought was a DPR scaling problem, every click started landing at half its correct position.

Here’s what eframe actually does on the web — I went and read the eframe source for this, because the docs didn’t tell me. It splits across two mechanisms:

  1. Canvas buffer sizing uses a ResizeObserver. When the browser supports devicePixelContentBoxSize, eframe reads the canvas size in physical device pixels directly and applies no DPR math at all. Otherwise it falls back to contentBoxSize (CSS pixels) multiplied by window.devicePixelRatio.
  2. Pointer mapping and UI scale (pixels_per_point) read window.devicePixelRatio separately.

The trap is the obvious fix. I reached for the usual canvas-DPR remedy — force devicePixelRatio to 1 from JavaScript — and that desynchronized the two halves. On Chromium and Firefox the buffer was still being sized in real physical pixels, but pointer mapping now believed the display was 1:1, so every click landed at half position on my 2x screen. I spent a day in there, fixing the symptom and making it worse.

The fix was to delete my fix. Let eframe read the real devicePixelRatio and both halves agree again, on every browser. The only correct move was no move at all — which is a humbling thing to land on after a day of moving.

Two things made this miserable to debug. First, headless browsers report DPR differently from real ones, so my Playwright checks kept telling me everything was fine when it wasn’t. Second, the devicePixelContentBoxSize path is barely documented — almost all the canvas-DPR advice I found online describes the contentBoxSize path, which behaves differently.

One scoping note, because I only learned this later: devicePixelContentBoxSize is a Chromium/Firefox feature. Safari doesn’t support it — MDN’s compatibility table is blunt about it, and eframe’s own 0.28 changelog says the ResizeObserver approach gives “pixel-perfect rendering on all known browsers except for Desktop Safari.” On Safari, eframe takes the CSS-pixels-times-DPR fallback, so the way it breaks when you lie about DPR is different — but the do-nothing fix is right everywhere.

What broke between 0.29 and 0.33

When I came back in 2026 to modernize the dependencies, the jump from 0.21 up to 0.33.3 crossed three breaking changes. I’m attributing them to the right versions on purpose, because none of them actually landed in 0.33:

  • WebRunner::start() switched from canvas id strings to HtmlCanvasElement in 0.29.
  • painter.rect_stroke() grew a required fourth StrokeKind argument, and Rounding became CornerRadius, in 0.31.
  • Slider::clamp_to_range gave way to SliderClamping in 0.29.

The compile errors were clear and the fixes were mechanical — the cost was volume, not mystery. But it’s a real cost, and egui’s README doesn’t hide it: “If you want something that doesn’t break when you upgrade it, egui isn’t for you (yet).” Take that at face value and budget the upgrade time.

What you give up

Everything above is the good half. Here’s what I gave up to ship egui on the web, and to be fair, none of it ambushed me — most of it is right there in eframe’s own README (“almost nothing else from the web tech stack” is used).

Your page’s typography doesn’t exist inside the canvas. egui rasterizes glyphs itself into a font atlas, and the README’s limitations list says it plainly: “No integration with browser settings for colors and fonts.” The 0.33 build the lab ships draws text with ab_glyph and no hinting, which is one concrete reason small text looks soft to me. egui 0.34 switched to skrifa + vello_cpu with hinting and that sharpens it up, but browser fonts are still off the table. No matter how much I tuned the palette, the UI reads as a desktop tool dropped into a web page, not as part of the page.

No CSS, no transitions, no easing. Everything is a painter call — filled rects, strokes, circles. The cell fade-ins and path-reveal animations a JS visualizer gets nearly for free turn into per-frame alpha bookkeeping in egui. I skipped them; the lab snaps between states instead of animating them. That was a choice I made to save my time, and you can see it.

Screen readers see nothing. egui supports AccessKit, but as of mid-2026 AccessKit’s web adapter is still listed as planned, not shipped. eframe ships an experimental opt-in screen reader of its own, but standard assistive tooling can’t see into the canvas at all. For a demo lab I decided that was a trade-off I could make and disclose. For anything actually user-facing, this is where I’d stop.

Cursor affordances need opting in. Here I have to correct something I believed while building it: eframe does map egui cursor icons to CSS cursors — the web backend sets the canvas cursor style every frame. What it doesn’t do is apply web conventions for you. Buttons won’t turn into a pointer unless you ask, via on_hover_cursor. The mechanism is there; the defaults are just desktop-shaped.

The load gap is real. The .wasm fetch-and-compile takes a noticeable beat — a couple hundred milliseconds on a fast connection, longer on mobile. I paint a dark backdrop and a loading wasm… overlay so there’s no white flash while it loads. A JS implementation simply doesn’t have this gap.

Mobile is a project of its own. egui thinks in pixels, not rem. There are no breakpoints, eframe fakes the on-screen keyboard with invisible DOM elements, and its README admits mobile text editing “is not as good as for a normal web app.” To make the lab’s fixed-width side panel genuinely comfortable on a phone I’d have to write my own layout branching — and I haven’t paid that bill.

The honest verdict

eframe’s README tells you what it’s for, directly: “web apps where performance and responsiveness are more important than accessibility and mobile text editing.” And egui’s: “If you want a GUI that looks native, egui is not for you.” Both of those held up against my build, every word. So here’s where I landed, and I’ll own it as my opinion: egui on the web is for developer tools, and for the cases where the WASM binary itself is the story.

If I’d built this same visualizer in canvas2d, it would look better with less code — system fonts, CSS transitions, responsive layout, all of it free. What it wouldn’t have is the algorithm layer underneath: the Rust SearchState design, the exhaustive matching, a compiler catching my state-machine mistakes at build time. For this lab the pipeline demo was the point, so the trade was worth it to me. It won’t always be.

So I’d reach for egui + eframe on the web when the app is a developer tool (a profiler, an inspector, a config editor), when I’m porting an existing desktop egui app and just want it running in a browser without much effort, or when demonstrating the Rust-to-WASM pipeline is the actual goal. I’d skip it when the UI has to match a design system, needs animation or easing, has to be accessible, or when a React/Vue/Svelte component would take a tenth of the time and look better — because it would.

For the wider picture: Rust GUI on the web really splits into two families. DOM renderers — Dioxus, Leptos — keep browser text, CSS, and accessibility, because they render real DOM nodes. Canvas renderers — egui, Slint, and friends — inherit every trade-off I just walked through; Slint’s own docs even steer general-purpose web apps away from its browser target. Picking egui on the web means picking the canvas family. Just pick it knowing that’s what you’re doing.

Would I do it again

For this lab, yes. I set out to learn what shipping Rust to the browser feels like, and now I know. But I’d walk in with my eyes open next time. I’d leave devicePixelRatio alone from the very first commit instead of discovering at the end why I should have. I’d keep the search code in plain Rust modules that test without a browser in sight, because that’s the part that aged the best. And I’d treat the dependency upgrade as a scheduled task with its own time budget, not as a surprise. None of that is a recipe — it’s just what I’m left with after doing it once.

The lab is live at /labs/pathfinding-viz. The algorithms were the fun part. The canvas was the bill.

  • Personalized ADAS in CARLA — another build where the simulation loop was the product, with very different tooling.
  • Valgrind on Linux — the same debugging discipline this DPR bug demanded, applied to native memory errors.
  • Lidar Robot — where my path-planning obsession started: SLAM and navigation on a real holonomic robot.