Skip to content

perf: synchronous render after user input to cut echo latency - #17

Merged
diegosouzapw merged 1 commit into
mainfrom
perf/upstream-issue-161-echo-latency
May 23, 2026
Merged

perf: synchronous render after user input to cut echo latency#17
diegosouzapw merged 1 commit into
mainfrom
perf/upstream-issue-161-echo-latency

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

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

  • New private field awaitingEcho (boolean, default false).
  • Set to true at the three points where input leaves the terminal:
    • The internal input-handler callback that fires dataEmitter on a keystroke
    • paste(data) (bracketed and unbracketed paths)
    • input(data, wasUserInput=true) (public API)
  • At the end of writeInternal, if the flag is true, 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 typecheck
  • bun test — 340 tests pass (1 new regression test for sync-render behaviour), 0 fail
  • bun run build:lib
  • bun run build:wasm (no WASM change, but rebuilt locally to confirm CI compat)
  • Manual smoke test pending in bun run dev: type fast in a real shell, confirm subjective latency drop

Risk

Low. One extra renderer.render() per keystroke (cheap — the renderer already skips clean rows via dirty tracking). No impact on the bulk-stdout path.

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)
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
23.8% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/terminal.ts
Comment on lines +643 to +646
if (this.awaitingEcho && this.renderer && this.wasmTerm) {
this.awaitingEcho = false;
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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();
      }
    }

@diegosouzapw
diegosouzapw merged commit 740452a into main May 23, 2026
1 of 2 checks passed
@diegosouzapw
diegosouzapw deleted the perf/upstream-issue-161-echo-latency branch May 23, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant