← Blog

Making a WebView terminal feel native

A terminal that lives inside a WebView starts with a handicap: it shares a thread and a scheduler with everything else the app does. v1.5 is where we closed the gap. This is the study behind it — off-main-thread rendering, a binary frame protocol, and the Apple Silicon scheduling discovery that finally made typing fluid while a cargo build saturates every core.

The cargo build that stuttered

The bug report was just a feeling: "typing gets mushy when a build is running." Easy to dismiss, hard to ignore once you've felt it. You kick off cargo build in one pane, switch to another to keep working, and the echo of your own keystrokes arrives a beat late. Hit Ctrl+C and it doesn't land for half a second. For a tool aimed at engineers who live in the terminal eight hours a day, "mushy under load" is not a papercut — it's the difference between a native terminal and a web app pretending to be one.

We'd already replaced xterm.js with an Alacritty-backed Canvas renderer, so we owned the grid end to end. That was the prerequisite. But owning the pipeline only matters if the pipeline gets CPU when it needs it. The stutter under load proved it didn't. So we went looking for why — and the answer turned out to be two separate problems wearing one symptom.

Two bottlenecks, one symptom

Input latency in a WebView terminal comes from two places, and they're easy to confuse:

  1. Main-thread contention. The WebView's JavaScript runs on a single thread. If rendering, IPC, and input handling all queue behind each other on that thread, a keystroke waits its turn. Under burst output the queue never drains.
  2. Scheduler priority. Even with a clear thread, the OS decides when your threads run. A cargo build spawns dozens of compiler threads that all want CPU. If the terminal's threads sit in the same priority class as rustc's workers, the scheduler treats your keystroke as no more urgent than a codegen unit.

You can't fix one and declare victory. Move rendering off the main thread and you still lose if the scheduler starves the render thread under load. Raise priority and you still lose if every paint blocks input on the same thread. We had to solve both.

Getting rendering off the main thread

The grid lives in Rust. The frontend's only job is to paint it. So the first move was to make painting not touch the main thread at all: the canvas runs in an OffscreenCanvas inside a dedicated Web Worker. Rust serializes only the rows that changed and ships them to the worker, which decodes and paints while the main thread stays free for input.

The wire format is deliberately dumb and tight — a binary frame, not JSON. A small fixed header, then a flat block of cells decoded into struct-of-arrays typed arrays with zero per-cell object allocation:

Rust backend alacritty grid + damage tracker binary frame dirty rows only Web Worker decode (SoA arrays) paint OffscreenCanvas Canvas pixels on screen main thread — stays free keystrokes · cursor overlay · IPC ack only paint path never touches the main thread
The grid is authoritative in Rust. Only changed rows cross the wire, as a binary frame, into a worker that paints an OffscreenCanvas — leaving the main thread for input.

This bought a lot. But it introduced a gotcha that cost a day to find: WebKit deprioritizes a worker's requestAnimationFrame for sporadic paints. Type two characters and the glyphs wouldn't appear until you pressed a third. The cursor (a main-thread overlay) moved instantly; the glyphs (the worker's base paint) trailed. The cause: with no continuous animation running, WebKit decides the worker's rAF isn't urgent and defers it — so single-keystroke repaints waited on a setTimeout fallback that was set to 100 ms. We dropped the fallback to 16 ms and the lag vanished. The lesson stuck: in a worker, never assume rAF fires promptly for one-off paints.

The ack loop that ate a core

Off-thread rendering needs back-pressure: the frontend acknowledges each frame so Rust knows when to send the next, instead of flooding the IPC channel. A refactor quietly broke this. The acknowledgement path started producing the next frame as a side effect — frontend acks → Rust serializes and sends → frontend receives and acks immediately → repeat, with no throttle in between. We measured 240+ IPC calls per second pinning the Tauri main thread at 100%, on an idle terminal.

The fix was to make the ack do exactly one thing — clear the in-flight flag — and let a single 8 ms ticker be the sole thing that ever sends a frame. One producer, one cadence, no feedback loop. The principle generalizes: an acknowledgement should release work, never schedule it.

We applied the same discipline to the raw output event. The desktop canvas renders from grid frames, so the per-chunk pty-output event (which only drives the activity dot) is now throttled to roughly ten emits a second. Under an output storm — say a runaway yes — emitting per chunk used to flood the WebView and starve keydown so badly that Ctrl+C couldn't get through. Dropping intermediate pulses is free; nobody needs the activity dot to blink 10,000 times a second.

The discovery: scheduling, not threading

With rendering off the main thread and the IPC loop tamed, the app was fast — until you saturated the machine. Under a 14-core cargo build, typing went mushy again. We'd fixed contention; the remaining culprit was priority.

We already gave PTY child processes a lower scheduling priority right after spawn — nice +10 on macOS/Linux, BELOW_NORMAL on Windows — so a build yields to the UI. On Intel that was enough. On Apple Silicon it wasn't, and the reason is subtle: the macOS scheduler on Apple Silicon is driven by Quality-of-Service bands, and nice only reorders threads within a band. Our interactive threads and the compiler's worker threads were sitting in the same default band, so nice couldn't lift our keystroke above their codegen.

macOS / Apple Silicon — the scheduler picks the highest band first QOS · USER_INTERACTIVE reader thread frame ticker keystroke write TUIC's interactive I/O path — raised here in v1.5 elevated out of the crowd QOS · DEFAULT where the interactive path used to live — shared with… nice +10 / BELOW_NORMAL cargo · rustc workers · bundlers — PTY children, deprioritized
On Apple Silicon, nice only reorders within a band. The fix was to raise the reader thread, frame ticker, and keystroke-write thread to USER_INTERACTIVE — a strictly higher band than where the compiler's workers run.

So v1.5 raises three threads — the PTY reader, the frame ticker, and the keystroke-write thread — to QOS_CLASS_USER_INTERACTIVE via pthread_set_qos_class_self_np. That puts the entire interactive path in a strictly higher scheduler band than the default one where compiler threads live. It complements the child renice rather than replacing it: renice keeps builds polite within their band; QoS elevation makes sure your keystroke is in a band above the whole crowd. On an M4 Max under full saturation, the UI goes from frozen to responsive. It's macOS-only — a no-op on Linux and Windows, where per-thread QoS has no useful equivalent and the renice already does the job.

This was the headline insight of the whole effort: past a point, terminal responsiveness on Apple Silicon isn't a threading problem or an algorithm problem. It's a scheduling problem, and the OS won't solve it for you unless you tell it which threads carry the human's intent.

Protecting input when the machine is truly pinned

One more layer, for the worst case. When the system load per core crosses a saturation threshold and you're actively typing, we briefly widen the minimum interval between grid frames. Frames drop by about a third while your fingers are moving, which hands those cycles back to echo and input. The moment you stop typing, full frame cadence returns; on an unloaded machine the throttle never engages at all. It's a small, surgical concession — sacrifice a little visual smoothness exactly when, and only when, fluid input matters more.

Scroll that doesn't drift

Native terminals don't duplicate lines when you scroll, don't go black when you zoom, and don't leave a ghost row hanging below the prompt. Getting there meant fixing three separate scroll defects, all rooted in the same trap: identifying a row by a coordinate that moves under you.

And the freezes

The most damaging "slowness" isn't latency — it's a multi-second freeze when you switch repos or open a tab. We caught it with runtime diagnostics: an in-flight grid frame flag stuck with a repeat count climbing into the hundreds, meaning the WebView's JS thread was blocked for minutes at a time. The root cause traced to a thundering-herd of visibility and repo-change events that an earlier batching fix had partly lost in the terminal rewrite. Restoring batched updates and a stuck-frame detector turned a creeping, restart-to-fix freeze into a non-event. (If you ever want to see what the app sees, POST /diagnostics {"enabled":true} on the local debug port emits a full health snapshot every 30 seconds — CPU split between TUIC and its PTY children, thread and FD counts, stuck frames, and more.)

The usability sharpening that shipped alongside

The same release tightened the everyday edges, because "fast" and "pleasant" are the same goal seen from two angles:

And the interaction fixes that quietly remove daily friction: Cmd+C no longer copies duplicated text and now unwraps soft-wrapped selections; a stale "awaiting input" badge clears when an agent exits to the shell; smooth-scroll snaps cleanly to the line when a gesture ends; the macOS dock icon reliably restores a minimized window even with a detached panel open.

Is it indistinguishable?

Close enough that the honest answer is "we stopped being able to tell in normal use." Typing stays crisp while a 14-core build runs. Scroll holds its position through scrollback eviction. Zoom repaints instantly. The freezes are gone. The remaining tell is raw throughput — Canvas 2D will never out-scroll a WebGL renderer on a cat of a 100 MB file — but that was a tradeoff we took on purpose, because agents emit text at LLM speed, not disk speed, and the bottleneck there is never the renderer.

What we keep coming back to is how little of this was about clever algorithms. It was about respecting the platform: keep the main thread for the human, tell the OS scheduler which threads carry intent, and never let an acknowledgement schedule work. Do that, and a terminal in a WebView stops feeling like a web app — and starts feeling like the tool you forget you're using.