Performance Optimization
View SourceTechniques for achieving 60fps terminal rendering.
Performance Targets
| Operation | Budget | Typical | Excellent |
|---|---|---|---|
| Buffer create | < 1ms | 0.3ms | 0.1ms |
| write_at (single) | < 100us | 50us | 20us |
| draw_box | < 500us | 240us | 150us |
| render_diff | < 2ms | 1.2ms | 0.5ms |
| Full render | < 16ms | 8ms | 4ms |
| LiveView update | < 16ms | 5ms | 2ms |
16ms per frame = 60fps.
Buffer Diffing
Only update what changed.
defmodule PerformantRenderer do
alias Raxol.Core.{Buffer, Renderer}
def render_loop(state) do
new_buffer = create_frame(state)
diff = Renderer.render_diff(state.buffer, new_buffer)
IO.write(Renderer.apply_diff(diff))
Process.sleep(16) # ~60fps
render_loop(%{state | buffer: new_buffer})
end
endWithout diffing: ~15ms for 80x24 buffer (clear + full redraw). With diffing: ~0.3ms for typical updates. 50x faster.
Smart Diffing
Full render every N frames or when buffer dimensions change:
defp major_change?(state, new_buffer) do
rem(state.frame_count, 60) == 0 or
state.buffer.width != new_buffer.width or
state.buffer.height != new_buffer.height
endCaching Strategies
Style Caching
Reuse style maps via module attributes (compile-time):
@header_style Style.new(bold: true, fg_color: :cyan)
@error_style Style.new(bold: true, fg_color: :red)
def render_dashboard(buffer, data) do
buffer
|> Buffer.write_at(5, 1, "Dashboard", @header_style)
|> Buffer.write_at(5, 3, data.message, message_style(data.status))
end
defp message_style(:ok), do: @success_style
defp message_style(:error), do: @error_style
defp message_style(_), do: %{}10-20% faster by avoiding style allocation.
Buffer Caching
Cache static parts of the UI:
# Cache the static frame
frame = BufferCache.get_or_create(:main_frame, fn ->
Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :double)
|> Buffer.write_at(10, 1, "My Application", %{bold: true})
end)
# Only update dynamic content
frame |> Buffer.write_at(10, 10, "Time: #{Time.utc_now()}")Lazy Rendering
Only render visible content.
Viewport Rendering
defmodule ViewportRenderer do
def render_viewport(data, viewport) do
buffer = Buffer.create_blank_buffer(viewport.width, viewport.height)
data
|> filter_visible(viewport)
|> Enum.reduce(buffer, fn item, buf ->
x = item.x - viewport.offset_x
y = item.y - viewport.offset_y
Buffer.write_at(buf, x, y, item.text, item.style)
end)
end
end100x faster for large datasets (render 24 rows instead of 1000+).
Virtual Scrolling
Only render visible rows in scrollable lists:
def render_list(buffer, items, scroll_offset, visible_rows) do
visible_items = Enum.slice(items, scroll_offset, visible_rows)
visible_items
|> Enum.with_index()
|> Enum.reduce(buffer, fn {item, idx}, buf ->
Buffer.write_at(buf, 2, idx + 2, format_item(item))
end)
|> add_scrollbar(scroll_offset, length(items), visible_rows)
end60fps Checklist
- [ ] Use diff rendering -- don't redraw everything
- [ ] Cache static content -- reuse unchanged buffers
- [ ] Minimize allocations -- reuse style maps
- [ ] Batch updates -- group operations
- [ ] Lazy render -- only render visible content
- [ ] Profile regularly -- measure before optimizing
- [ ] Set frame budget -- warn if > 16ms
- [ ] Test on slow hardware
Frame Budget Monitor
defmodule FrameBudget do
@fps_60_budget_us 16_000
def render_with_budget(render_fn) do
{time_us, result} = :timer.tc(render_fn)
if time_us > @fps_60_budget_us do
Logger.warn("Slow render: #{time_us}us (> #{@fps_60_budget_us}us)")
end
result
end
endCommon Pitfalls
Creating styles repeatedly
# Bad: new style map each iteration
Enum.each(lines, fn line ->
Buffer.write_at(buffer, 0, line, "Text", %{fg_color: :cyan})
end)
# Good: reuse style
style = %{fg_color: :cyan}
Enum.reduce(lines, buffer, fn line, buf ->
Buffer.write_at(buf, 0, line, "Text", style)
end)30% faster for 100+ writes.
Full redraws
# Bad: clear and redraw everything
IO.write("\e[2J\e[H")
IO.puts(Buffer.to_string(buffer))
# Good: diff only changed cells
diff = Renderer.render_diff(old_buffer, new_buffer)
IO.write(Renderer.apply_diff(diff))50x faster.
Blocking in render loop
# Bad: sync HTTP call in render loop
data = HTTPClient.get("/api/stats") # blocks!
# Good: async fetch, render from cache
buffer = create_frame(state.cached_data)Profiling
Manual
{time, result} = :timer.tc(fn -> Buffer.create_blank_buffer(80, 24) end)
IO.puts("Create buffer: #{time}us (#{time / 1000}ms)")Benchee
Benchee.run(%{
"create_buffer" => fn -> Buffer.create_blank_buffer(80, 24) end,
"draw_box" => fn -> Box.draw_box(buffer, 0, 0, 80, 24, :double) end,
"diff_render" => fn ->
new = Buffer.write_at(buffer, 40, 12, "X")
Renderer.render_diff(buffer, new)
end
}, time: 5, memory_time: 2)Performance Tests
test "full frame render meets 60fps budget" do
buffer = create_complex_frame()
{time, _} = :timer.tc(fn -> Buffer.to_string(buffer) end)
assert time < 16_000, "Full render too slow: #{time}us"
endBenchmarks
See docs/bench/README.md for the full benchmark suite comparing Raxol against Ratatui, Bubble Tea, and Textual.