Performance Optimization

View Source

Techniques for achieving 60fps terminal rendering.

Performance Targets

OperationBudgetTypicalExcellent
Buffer create< 1ms0.3ms0.1ms
write_at (single)< 100us50us20us
draw_box< 500us240us150us
render_diff< 2ms1.2ms0.5ms
Full render< 16ms8ms4ms
LiveView update< 16ms5ms2ms

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
end

Without 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
end

Caching 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
end

100x 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)
end

60fps 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
end

Common 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"
end

Benchmarks

See docs/bench/README.md for the full benchmark suite comparing Raxol against Ratatui, Bubble Tea, and Textual.

Next Steps