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, but enough to know what it actually takes to ship a real Rust GUI to the web. Poking around for how people do this, I kept landing on egui: an immediate-mode GUI library that compiles to WebAssembly and, per its own README, “the simplest way to make a web app in Rust.” That was exactly the experiment I wanted to run.

You cannot evaluate a UI toolkit by reading its docs, so I needed something real to build with it. I picked a pathfinding visualizer — a grid, a few search algorithms, live animation, mouse interaction to draw walls. Enough moving parts to stress the whole pipeline: rendering, input, layout, and the Rust-to-browser boundary. The result is live at /labs/pathfinding-viz; play with it before you read my conclusions.

This is a post-mortem of that experiment, not a tutorial. If you are deciding whether egui + eframe belongs in your own web project, what follows is what the decision actually costs.

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

Getting it into the browser was the easy part

This surprised me, because I expected the Rust-to-web boundary to be the hard bit. It was not. The whole lab is one small Rust crate, and wasm-pack turns it into a .wasm binary plus a little JavaScript glue. The only sharp edge I hit was that wasm-pack resolves its output directory relative to the crate rather than my shell, so I learned to hand it absolute paths after it scattered files somewhere I did not expect.

What made it painless was building with the web target, which emits a plain ES module the browser imports directly. Vite needed no special configuration: a tiny React island imports the glue, kicks off the fetch-and-compile of the binary, and then hands eframe the canvas. From that instant eframe owns the canvas and neither React nor Astro ever touches it again. The whole integration is a handful of lines, and the repository has them if you want to read them — pasting them here would not teach you anything the sentence already did.

Coming back to this in 2026 added one piece of trivia worth passing on: the rustwasm GitHub organization was sunset in mid-2025. wasm-pack survives under an independent maintainer, but the canonical docs URL I had bookmarked is dead now. If you go looking, link the maintained book, not the tutorial you remember.

So the part I had braced for turned out to deliver exactly what it promised. The friction was everywhere else.

Immediate mode is the right shape for this

egui is immediate-mode: there is no retained widget tree and no callbacks. Every frame, eframe asks you to describe the entire UI from scratch. For a search visualizer that is not overhead, it is the whole idea — the render loop and the simulation loop collapse into the same loop. Each frame I advance the search a few steps and then paint every cell exactly as it stands at that instant. There is 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 actually enjoyed was the search code underneath. One small state struct serves all four algorithms, the compiler forces me to 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 would have trusted it less. If the story ended here it would read like a love letter to Rust on the web.

It does not end here.

The canvas sizing trap

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

What eframe actually does on the web — verified against the eframe source, not the docs — is split 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. Reach for the usual canvas-DPR remedy — force devicePixelRatio to 1 from JavaScript — and you desynchronize the two halves. On Chromium and Firefox the buffer is still sized in real physical pixels, but pointer mapping now believes the display is 1:1, so every click lands at half position on a 2x screen. I spent a day there, fixing the symptom and making it worse.

The actual fix was to delete my fix. Let eframe read the real devicePixelRatio and both halves agree again, on every browser. The only correct intervention was no intervention — which is a humbling thing to conclude after a day of intervening.

Two things made this miserable to debug. First, headless browsers report DPR differently from real ones, so my Playwright checks kept telling me things were fine when they were not. Second, the devicePixelContentBoxSize path is barely documented — most canvas-DPR advice online describes the contentBoxSize path, which behaves differently.

One scoping note: devicePixelContentBoxSize is a Chromium/Firefox feature. Safari does not support it — MDN’s compatibility table is blunt, and eframe’s own 0.28 changelog says the ResizeObserver approach yields “pixel-perfect rendering on all known browsers except for Desktop Safari.” On Safari, eframe takes the CSS-pixels-times-DPR fallback, so the failure mode of lying about DPR differs — but the do-nothing fix is correct 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 — worth attributing correctly, 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 are clear and the fixes are mechanical; the cost is volume, not mystery. But it is a real cost, and egui’s README is refreshingly honest about it: “If you want something that doesn’t break when you upgrade it, egui isn’t for you (yet).” Budget upgrade time accordingly.

What you give up

Everything above is the good half. Here is what shipping egui on the web actually costs, and none of it is hidden — most of it is stated plainly in eframe’s own README (“almost nothing else from the web tech stack” is used).

Your page’s typography does not exist inside the canvas. egui rasterizes glyphs itself into a font atlas; the README’s limitations list is blunt: “No integration with browser settings for colors and fonts.” The 0.33 build the lab ships renders text with ab_glyph and no hinting — one concrete reason small text looks soft. egui 0.34 switched to skrifa + vello_cpu with hinting, which sharpens it, but browser fonts remain off the table. However much you tune the palette, the UI reads as a desktop tool embedded in 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 would get nearly for free require per-frame alpha bookkeeping in egui. I skipped them; the lab snaps states instead of animating them.

Screen readers see nothing. egui supports AccessKit, but AccessKit’s web adapter is still listed as planned, not shipped, as of mid-2026. eframe offers an experimental opt-in screen reader of its own, but standard assistive tooling cannot see into the canvas. For a demo lab this is a trade-off I could accept and disclose; for anything user-facing it should end the discussion.

Cursor affordances need opting in. One correction to what I believed while building: eframe does map egui cursor icons to CSS cursors — the web backend sets the canvas cursor style every frame. What it does not do is apply web conventions by default: buttons do not become pointer unless you ask via on_hover_cursor. The mechanism exists; the defaults are 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. The lab paints a dark backdrop and a loading wasm… overlay to avoid a white flash. A JS implementation has no equivalent 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 concedes mobile text editing “is not as good as for a normal web app.” Making the lab’s fixed-width side panel genuinely comfortable on a phone would mean implementing my own layout branching — a bill I have not paid.

The honest verdict

eframe’s README states the suggested use 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 sentences survived contact with my build. My conclusion, stated as mine: egui on the web is for developer tools, and for cases where the WASM binary itself is the story.

A canvas2d implementation of this visualizer would look better with less code — system fonts, CSS transitions, responsive layout, all free. What it would not have is the algorithm layer: the Rust SearchState design, exhaustive matching, and a compiler that catches state-machine mistakes at build time. For this lab, the pipeline demo WAS the point, so the trade made sense. It will not always.

Reach for egui + eframe on the web when the app is a developer tool (profiler, inspector, config editor), when you are porting an existing desktop egui app and want it running in a browser with minimal effort, or when demonstrating the Rust-to-WASM pipeline is itself the goal. Skip it when the UI must match your design system, needs animation or easing, must be accessible, or when a React/Vue/Svelte component would take a tenth of the time and look better — because it will.

For the wider landscape: Rust GUI on the web 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 described above; Slint’s own docs steer general-purpose web apps away from its browser target. Choosing egui on the web is choosing the canvas family. Choose it on purpose.

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 would walk in with my eyes open. I would leave devicePixelRatio alone from the first commit instead of discovering at the last why I should. I would keep the search code in plain Rust modules that test without a browser anywhere in sight, because that part aged the best. And I would treat a dependency upgrade as a scheduled task with its own time budget, not a surprise. None of that is a recipe — it is just what is left 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.