Performance
Wijjit renders through a virtual DOM and a cell-based screen buffer, then writes only the cells that actually changed. The practical consequence is that a Wijjit app is cheap to update, even when it is expensive to draw.
The headline number
On a 200x60 terminal, a full repaint of a dashboard (a table, two charts, and a button row) writes 15,980 bytes. Advancing the sparkline by one tick writes 37 bytes. An idle frame – one where nothing changed – writes nothing at all.
That is a ~430x reduction in terminal traffic for a typical update, and it is why a Wijjit app does not flicker and stays responsive over SSH or a slow serial link.
What the diff renderer buys you
The primary win is I/O and stability: an unchanged screen produces no output, and a small change produces a small write. Terminal writes are the expensive, latency-bound part of a TUI, and they are what causes visible flicker. The byte counts below are the figure to trust when comparing – they are deterministic, while timings vary with your CPU and system load.
The incremental path is also cheaper in CPU than a full repaint. Each frame
starts from a copy of the previous one, skips elements whose content and position
did not change, and diffs only what moved – so a localized edit does work
proportional to what changed rather than to the size of the view. In steady
state that is roughly 2x cheaper than a full repaint on a form of independent
widgets. The gain is smaller on the dashboard below, whose Table repaints
wholesale rather than skipping unchanged rows.
A full repaint – the worst case, on first paint or a terminal resize – still touches every cell.
The virtual-DOM reconciler also preserves state – cursor position, scroll offset, selection – across re-renders, and reuses element objects instead of rebuilding them every frame. So the things you expect to persist while the UI updates around them simply do.
Measured results
These come from a benchmark script in the source repository
(scripts/bench_perf.py), which you can run to reproduce them on your own
machine. Timings depend on your CPU and system load; treat them as indicative.
The byte counts are deterministic and are covered by the project’s test suite, so
they do not drift between releases.
Terminal I/O per frame
Scenario |
Size |
Full repaint |
Idle frame |
One change |
Reduction |
|---|---|---|---|---|---|
hello world |
80x24 |
3,364 B |
0 B |
7 B |
481x |
hello world |
200x60 |
15,640 B |
0 B |
7 B |
2234x |
login form |
80x24 |
3,568 B |
0 B |
35 B |
102x |
login form |
200x60 |
15,844 B |
0 B |
35 B |
453x |
dashboard (table + charts) |
80x24 |
3,676 B |
0 B |
37 B |
99x |
dashboard (table + charts) |
200x60 |
15,980 B |
0 B |
37 B |
432x |
Render latency
“Steady state” is the median time to produce one frame in which one small thing changed – the cost of an animation tick or a keystroke echo.
Unlike the byte counts, these vary by tens of percent between runs on the same machine, so they are rounded. Do not read precision into them, and prefer the byte figures above when comparing.
Scenario |
Size |
Full repaint |
Steady state |
|---|---|---|---|
hello world |
80x24 |
~2 ms |
~1.5 ms |
hello world |
200x60 |
~6 ms |
~3 ms |
login form |
80x24 |
~2.5 ms |
~2 ms |
login form |
200x60 |
~6 ms |
~3.5 ms |
dashboard (table + charts) |
80x24 |
~7 ms |
~6.5 ms |
dashboard (table + charts) |
120x40 |
~7.5 ms |
~7 ms |
dashboard (table + charts) |
200x60 |
~10 ms |
~6.5 ms |
Measured on Windows 11, Python 3.13, wijjit 0.1.0.
Steady state beats a full repaint in every row, because unchanged elements are
skipped rather than redrawn. The margin is widest where the changed widget is
small relative to the view (hello world at 200x60) and narrowest on the
dashboard, whose Table repaints wholesale.
For context: terminal applications are driven by human input, so what matters is that a frame lands well inside a keystroke’s worth of time – not a frame-rate number. A dashboard that re-renders in ~10 ms at 200x60 feels immediate, and the bytes it writes are what determine whether that holds over SSH.
Startup cost
import wijjit takes roughly 700 ms in a cold process. Most of that is
prompt_toolkit (~350 ms), which Wijjit imports eagerly for cross-platform
terminal input.
If you are writing a CLI where startup latency matters and the TUI is only one subcommand, import Wijjit lazily inside that subcommand rather than at module scope.
What is slow, and what to avoid
Screen area dominates. Render cost scales with width x height, not with
the number of widgets. Going from 80x24 to 200x60 is a 6.25x area increase and
costs roughly 6x the time. A full-screen app on a large monitor is the worst
case.
There is no virtual scrolling. Every row of a Table, ListView, or
Tree is laid out on every render, whether or not it is visible. A few
thousand rows is fine; a hundred thousand is not. If you have a very large
dataset, page or filter it before handing it to the element.
Charts and tables are the expensive widgets. Table renders through Rich,
and the charts rasterize to braille or block glyphs. If a frame is slow, those
are the first things to look at.
Interpolating state into template *source* defeats the compiled-template cache. Pass changing values as context variables:
# Good: the template compiles once and is cached.
return render_template_string(TEMPLATE, count=app.state.n)
# Bad: a new template source string on every render, recompiled every time.
return render_template_string(f"Count: {app.state.n}")
Profiling your own app
The headless harness runs the real event loop without a TTY, which makes a Wijjit app straightforward to profile:
import cProfile
import pstats
from wijjit.testing import WijjitHarness
with WijjitHarness(app, size=(200, 60)) as h:
h.tick() # warm up: first paint, template compile
profiler = cProfile.Profile()
profiler.enable()
h.tick(frames=100)
profiler.disable()
pstats.Stats(profiler).sort_stats("tottime").print_stats(20)
Most render time is spent in ScreenBuffer and Cell operations. If your
own code shows up above those, that is where to optimize first.