Measured against v0.1.2-alpha.4 (4abcf33), desktop/ frontend.
node_modules/@xterm/ contains addon-fit, addon-search and xterm. There is no WebglAddon or CanvasAddon anywhere in desktop/src or desktop/package.json, so every terminal runs on xterm's DOM renderer — the fallback path, which rebuilds one <span> per styled run per row every time the screen changes. TerminalPane.vue:307-314 constructs the terminal and loads FitAddon and SearchAddon, and nothing else.
Switching to @xterm/addon-webgl is worth doing, but it is not free, and two of its costs are the kind that produce a blank terminal rather than a slow one. Both are measured below.
What the renderer actually costs
All numbers measured on this machine (Windows 11, RTX 5060 Ti). Playwright's Chromium driven through ANGLE → D3D11 — the same graphics path WebView2 uses on Windows — with vsync and the frame-rate limit disabled so a frame takes as long as the work in it. The corpus is 4 MiB of realistic terminal output: a third heavily styled (eight colored runs per row, ls --color-shaped), a third plain bulk text, a third build-log lines with a few styled runs each.
Render-bound streaming — one 8 KiB chunk delivered per animation frame, so the renderer draws every frame. This is the case a user feels: output arriving steadily, fast enough to redraw but not fast enough to coalesce.
| terminal |
DOM frame |
WebGL frame |
speedup |
DOM throughput |
WebGL throughput |
| 120x40 |
4.54 ms |
0.73 ms |
6.2x |
1.72 MiB/s |
10.72 MiB/s |
| 200x50 |
5.01 ms |
0.90 ms |
5.6x |
1.56 MiB/s |
8.68 MiB/s |
| 80x24 |
1.85 ms |
0.52 ms |
3.6x |
4.22 MiB/s |
14.97 MiB/s |
p95 frame time tracks the mean closely (6.4 → 0.9 ms at 120x40), so this is a shift in the whole distribution rather than a tail effect. The win scales with the number of cells, which is the expected shape: the DOM renderer's cost is per styled run per row.
Burst drain — the whole 4 MiB written as fast as term.write accepts it, timed until parsed and drawn:
| terminal |
DOM |
WebGL |
speedup |
| 120x40 |
37.7 MiB/s |
52.9 MiB/s |
1.40x |
| 200x50 |
38.5 MiB/s |
41.9 MiB/s |
1.09x |
| 80x24 |
43.5 MiB/s |
41.1 MiB/s |
0.94x |
This is the honest counterpart and it should not be buried. When output arrives faster than the screen refreshes, xterm coalesces and draws only the final state, so the work is in the VT parser, not the renderer, and the renderer barely matters. cat bigfile is close to this case. The 5-6x figure applies to sustained interactive-rate output, not to everything.
A note on method: measuring renderRows() in isolation shows only ~2x, not ~6x. That number understates the DOM renderer, because most of its cost is the style, layout and paint of the swapped-in spans, which the browser charges to the frame after the JS call returns. The frame-interval figures above capture that and are the ones to trust.
Cost 1: the GPU context budget is a real constraint here
Chromium keeps a fixed number of WebGL contexts alive per renderer process and kills the oldest without warning past that. Measured directly — 64 webgl2 contexts created in one page, then counted:
created: 64 still alive: 16 lost: 48 first lost: the oldest
The cap is exactly 16, and eviction is oldest-first. This matters more in Clavyn than in most apps, because TerminalWorkspace.vue:101-102 deliberately mounts every pane of every tab at once — that is correct and prevents xterm remounts when panes move between tabs, but it means the number of live terminals is not bounded by what is on screen. A user with seventeen terminals open across tabs would silently kill the first one.
Attaching the addon to 24 terminals and waiting confirms it end to end: terminals 1-8 lost their contexts, onContextLoss fired for exactly those eight, and 16 stayed alive.
Cost 2: context loss is a three-second blank, not an instant fallback
Reading the installed addon rather than assuming: on webglcontextlost it calls preventDefault() and starts a 3000 ms timer, firing onContextLoss only if the context has not been restored by then. Measured: the event arrived at 3075 ms after the loss.
So an unhandled — or even a correctly handled — context loss leaves that pane frozen for three seconds. Handling it is still mandatory, because without the dispose() the pane never comes back at all. Verified on the installed version rather than taken on faith:
WebglAddon.activate() registers a disposable that calls renderService.setRenderer(terminal._core._createRenderer()) and handleResize(cols, rows).
_createRenderer() in @xterm/xterm 5.5.0 returns DomRenderer.
- After
addon.dispose() the live renderer's constructor is identical to a never-accelerated terminal's, output written afterwards appears in the DOM, and nothing throws.
Attach costs 19 ms warm and 57 ms cold (shader compile plus texture atlas); detach costs 6 ms. That rules out the obvious mitigation of disposing on hide and re-attaching on show — it would put a 19 ms hitch on every tab switch.
Cost 3: the transcript leaves the DOM
This one has no workaround and is worth stating plainly. The GPU renderer draws to a canvas, so .xterm-rows does not exist and the terminal's text is not in the DOM at all. Two consequences:
- Five assertions across four browser regression suites (
terminal-workspace.mjs:59, terminal-input-routing.mjs:163, terminal-keyboard-close.mjs:137,140, terminal-fault-isolation.mjs:74) read a pane's transcript from .xterm-rows. They do not fail loudly — they time out.
- The app sets
screenReaderMode: false, so xterm builds no accessibility layer under either renderer. Today a screen reader traverses the DOM rows incidentally; under WebGL there is nothing there to traverse. This is not xterm's intended accessibility path (that is screenReaderMode, which has its own cost), but it is a real change in what assistive technology can reach.
Bundle
The addon is 101.22 kB raw / 26.06 kB gzip. Imported on demand it becomes its own chunk and the boot-critical entry chunk grows by 1.33 kB raw / 0.43 kB gzip (653.47 → 654.80 kB), which is the budget module itself. Imported statically it would land the whole 101 kB in the entry chunk and undo a large part of #35's work.
Assessment
Worth doing, with the context budget and the loss handler treated as part of the feature rather than as hardening added later. A least-recently-used cap of eight GPU-backed panes keeps a heavy workspace at half the browser's limit and degrades to the DOM renderer — which is what every pane uses today — instead of to a blank pane. The accessibility change in cost 3 is the one that deserves a decision rather than a default.
PR to follow.
Measured against
v0.1.2-alpha.4(4abcf33),desktop/frontend.node_modules/@xterm/containsaddon-fit,addon-searchandxterm. There is noWebglAddonorCanvasAddonanywhere indesktop/srcordesktop/package.json, so every terminal runs on xterm's DOM renderer — the fallback path, which rebuilds one<span>per styled run per row every time the screen changes.TerminalPane.vue:307-314constructs the terminal and loadsFitAddonandSearchAddon, and nothing else.Switching to
@xterm/addon-webglis worth doing, but it is not free, and two of its costs are the kind that produce a blank terminal rather than a slow one. Both are measured below.What the renderer actually costs
All numbers measured on this machine (Windows 11, RTX 5060 Ti). Playwright's Chromium driven through ANGLE → D3D11 — the same graphics path WebView2 uses on Windows — with vsync and the frame-rate limit disabled so a frame takes as long as the work in it. The corpus is 4 MiB of realistic terminal output: a third heavily styled (eight colored runs per row,
ls --color-shaped), a third plain bulk text, a third build-log lines with a few styled runs each.Render-bound streaming — one 8 KiB chunk delivered per animation frame, so the renderer draws every frame. This is the case a user feels: output arriving steadily, fast enough to redraw but not fast enough to coalesce.
p95 frame time tracks the mean closely (6.4 → 0.9 ms at 120x40), so this is a shift in the whole distribution rather than a tail effect. The win scales with the number of cells, which is the expected shape: the DOM renderer's cost is per styled run per row.
Burst drain — the whole 4 MiB written as fast as
term.writeaccepts it, timed until parsed and drawn:This is the honest counterpart and it should not be buried. When output arrives faster than the screen refreshes, xterm coalesces and draws only the final state, so the work is in the VT parser, not the renderer, and the renderer barely matters.
cat bigfileis close to this case. The 5-6x figure applies to sustained interactive-rate output, not to everything.A note on method: measuring
renderRows()in isolation shows only ~2x, not ~6x. That number understates the DOM renderer, because most of its cost is the style, layout and paint of the swapped-in spans, which the browser charges to the frame after the JS call returns. The frame-interval figures above capture that and are the ones to trust.Cost 1: the GPU context budget is a real constraint here
Chromium keeps a fixed number of WebGL contexts alive per renderer process and kills the oldest without warning past that. Measured directly — 64
webgl2contexts created in one page, then counted:The cap is exactly 16, and eviction is oldest-first. This matters more in Clavyn than in most apps, because
TerminalWorkspace.vue:101-102deliberately mounts every pane of every tab at once — that is correct and prevents xterm remounts when panes move between tabs, but it means the number of live terminals is not bounded by what is on screen. A user with seventeen terminals open across tabs would silently kill the first one.Attaching the addon to 24 terminals and waiting confirms it end to end: terminals 1-8 lost their contexts,
onContextLossfired for exactly those eight, and 16 stayed alive.Cost 2: context loss is a three-second blank, not an instant fallback
Reading the installed addon rather than assuming: on
webglcontextlostit callspreventDefault()and starts a 3000 ms timer, firingonContextLossonly if the context has not been restored by then. Measured: the event arrived at 3075 ms after the loss.So an unhandled — or even a correctly handled — context loss leaves that pane frozen for three seconds. Handling it is still mandatory, because without the
dispose()the pane never comes back at all. Verified on the installed version rather than taken on faith:WebglAddon.activate()registers a disposable that callsrenderService.setRenderer(terminal._core._createRenderer())andhandleResize(cols, rows)._createRenderer()in@xterm/xterm5.5.0 returnsDomRenderer.addon.dispose()the live renderer's constructor is identical to a never-accelerated terminal's, output written afterwards appears in the DOM, and nothing throws.Attach costs 19 ms warm and 57 ms cold (shader compile plus texture atlas); detach costs 6 ms. That rules out the obvious mitigation of disposing on hide and re-attaching on show — it would put a 19 ms hitch on every tab switch.
Cost 3: the transcript leaves the DOM
This one has no workaround and is worth stating plainly. The GPU renderer draws to a canvas, so
.xterm-rowsdoes not exist and the terminal's text is not in the DOM at all. Two consequences:terminal-workspace.mjs:59,terminal-input-routing.mjs:163,terminal-keyboard-close.mjs:137,140,terminal-fault-isolation.mjs:74) read a pane's transcript from.xterm-rows. They do not fail loudly — they time out.screenReaderMode: false, so xterm builds no accessibility layer under either renderer. Today a screen reader traverses the DOM rows incidentally; under WebGL there is nothing there to traverse. This is not xterm's intended accessibility path (that isscreenReaderMode, which has its own cost), but it is a real change in what assistive technology can reach.Bundle
The addon is 101.22 kB raw / 26.06 kB gzip. Imported on demand it becomes its own chunk and the boot-critical entry chunk grows by 1.33 kB raw / 0.43 kB gzip (653.47 → 654.80 kB), which is the budget module itself. Imported statically it would land the whole 101 kB in the entry chunk and undo a large part of #35's work.
Assessment
Worth doing, with the context budget and the loss handler treated as part of the feature rather than as hardening added later. A least-recently-used cap of eight GPU-backed panes keeps a heavy workspace at half the browser's limit and degrades to the DOM renderer — which is what every pane uses today — instead of to a blank pane. The accessibility change in cost 3 is the one that deserves a decision rather than a default.
PR to follow.