perf: synchronous render after user input to cut echo latency - #17
Conversation
The terminal renders via a permanent requestAnimationFrame loop. When a
browser tab is partially backgrounded, when the frame budget is tight,
or when rAF is otherwise deferred, the gap between sending a keystroke
and seeing the echo can grow to a full frame longer than the PTY
round-trip — typing feels sluggish.
Track an `awaitingEcho` flag that is set whenever input is emitted via
`onData` (keyboard input handler callback, paste, public `input(data,
true)` API). When the next `write()` arrives, presumed to carry the
echo bytes from the PTY, the terminal does a synchronous render right
after the WASM write instead of waiting for the next rAF tick.
After the synchronous render the dirty rows are cleared, so the rAF
loop has nothing to redraw on its next tick — no double-paint cost.
The flag only fires once per user input → no impact on bulk stdout.
Adds a regression test that monkey-patches `renderer.render` to count
synchronous calls. `term.input('x', true)` followed by `term.write('x')`
must increment the counter (synchronous render fired); a subsequent
`term.write(...)` without prior input must NOT (flag was cleared).
Reported-by: ruoso (coder#161)
|
There was a problem hiding this comment.
Code Review
This pull request introduces an optimization to reduce perceived echo latency by performing a synchronous render immediately after user input is processed, rather than waiting for the next requestAnimationFrame tick. A new test suite has been added to verify that synchronous rendering occurs as expected. Feedback indicates that this synchronous render path currently bypasses cursor movement tracking, which could lead to delayed onCursorMove events; it is recommended to synchronize the cursor state and fire the corresponding event within this new logic block.
| if (this.awaitingEcho && this.renderer && this.wasmTerm) { | ||
| this.awaitingEcho = false; | ||
| this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); | ||
| } |
There was a problem hiding this comment.
While this synchronous render correctly addresses echo latency, it bypasses the cursor movement tracking logic present in the main render loop. This could result in a delay of up to one frame for onCursorMove events after a keystroke, as the next requestAnimationFrame tick will be the one to detect the change and fire the emitter. Consider checking for cursor movement here as well to keep the state and events fully synchronized with the visual update.
if (this.awaitingEcho && this.renderer && this.wasmTerm) {
this.awaitingEcho = false;
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
// Sync cursor state and fire event if it moved during this sync render
const cursor = this.wasmTerm.getCursor();
if (cursor.y !== this.lastCursorY) {
this.lastCursorY = cursor.y;
this.cursorMoveEmitter.fire();
}
}

Summary
Fixes upstream issue #161: when a browser tab defers
requestAnimationFrame, the gap between sending a keystroke and seeing the echoed character grows by up to one frame on top of the actual PTY round-trip — typing feels laggy.Mechanism
awaitingEcho(boolean, defaultfalse).trueat the three points where input leaves the terminal:dataEmitteron a keystrokepaste(data)(bracketed and unbracketed paths)input(data, wasUserInput=true)(public API)writeInternal, if the flag istrue, the renderer is called synchronously (then the flag is cleared). The rAF loop sees a clean buffer on its next tick — no extra cost beyond one render per keystroke.This matches the reporter's recommended approach. The optimization is keyed off USER input only, so high-volume stdout (long builds, tail -f, etc.) is unaffected.
Attribution
Thanks to @ruoso for the report and the specific fix sketch.
Test plan
bun run fmt && bun run lint && bun run typecheckbun test— 340 tests pass (1 new regression test for sync-render behaviour), 0 failbun run build:libbun run build:wasm(no WASM change, but rebuilt locally to confirm CI compat)bun run dev: type fast in a real shell, confirm subjective latency dropRisk
Low. One extra
renderer.render()per keystroke (cheap — the renderer already skips clean rows via dirty tracking). No impact on the bulk-stdout path.