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:
- 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.
- Scheduler priority. Even with a clear thread, the OS decides when your threads run. A
cargo buildspawns dozens of compiler threads that all want CPU. If the terminal's threads sit in the same priority class asrustc'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:
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.
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.
- Eviction-stable row identity. The smooth-scroll row cache keyed rows by a coordinate that shifted every time the scrollback cap (10,000 lines) rotated and evicted the oldest line. After enough output, a cached row could alias onto a different line. We re-keyed the cache by an absolute index anchored to a
historyBasecounter that only ever grows, so a row's identity never changes once it exists. - Zoom-black. Zooming resized the grid but emitted no frame, because the reader thread only sends on PTY data or the ticker — and static, idle-agent content produces neither. The screen blanked until a scroll forced a redraw. Now a resize captures the freshly damaged rows and sends a frame immediately.
- Overscan ghost. The smooth-scroll overscan canvas extends one row past the viewport; at rest it could leave a stale line peeking below the prompt. We now wipe the overscan canvas on every return to rest.
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:
- Context-menu shortcut keys — with a menu open, press an item's chord (the branch menu's
M/R/d) to fire it directly instead of just dismissing the menu. - "Active only" repo filter — one click hides every repo with no open terminals, with an accent banner showing the shown/total count.
- Inline git blame in the editor — a dim GitLens-style "author · time · summary" at the end of the active line, fetched on load/save, never per keystroke.
- One-click delete-merged branches — a broom button bulk-deletes branches already merged into main, behind a confirm dialog, using safe
git branch -dso it can never drop unmerged work. - User-prompt scrollbar markers — green ticks mark every line where you submitted a command, so you can jump back to an earlier prompt at a glance.
- 250 MB files in the editor — a dedicated read path lifts the editor ceiling; oversized files are refused up front with a notice instead of freezing the UI mid-load.
- Cmd/Ctrl+R reloads a web or preview tab — the Run shortcut does the obvious thing when a preview is focused.
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.