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.