Skip to content

Async Application Examples

Async Applications gives the rules for combining Ratatui with an async runtime: one terminal owner, blocking draw boundaries, bounded channels, burst draining, and stale-work guards. This page is the companion source survey. It audits what older async tutorials teach before they teach the ownership contract, then walks through real applications — Ratatui apps, Crossterm TUIs, and adjacent terminal stacks — showing how each one structures terminal ownership, draining, backpressure, cancellation, and handoffs.

Older Ratatui async tutorials and templates are historical material, but many of them teach the shape of an async loop before they teach the terminal ownership contract. If an application was copied from them, review it for these gaps before adding more async work:

  • Entry point as architecture. A Tokio entry point lets the program spawn tasks and await futures, but it does not change the terminal into an async resource. Choose the terminal owner first, then decide which background jobs send messages into that owner.
  • Terminal work split by task name. The old async-template book sketches a render task and an event task as independent lanes. If one lane owns EventStream and another owns Terminal::draw, terminal queries, suspend/resume, child TUI handoffs, and inline viewport setup become harder to order. Prefer one terminal owner that reads events and draws, with other tasks sending application messages or render requests.
  • EventStream as ordinary async stdin. It is a stream-shaped API, but Crossterm implements it with a helper thread and the same internal event reader used by terminal queries. Code derived from older examples should not add direct event::read / poll, cursor::position(), keyboard-enhancement queries, OSC color queries, or child-process terminal handoffs beside an active EventStream. Route those operations through the terminal owner, or stop the stream before handing the terminal to something else.
  • Frames triggered by time or by single events. The current async-github example and simple-async template use a fixed draw interval because that keeps the example compact. The event-driven-async template wraps crossterm events, ticks, and app events in one event channel, but an app based on it still needs to decide when to drain, coalesce, and draw. In a larger app, fixed intervals and one-event-per-frame loops can hide lag: bursty input, process output, and resize events queue up while each stale frame is being written. Add a dirty flag, drain currently available work before drawing, and coalesce repeated redraw requests.
  • Shared state as hidden event flow. The async-github example uses Arc and RwLock for a one-shot background fetcher, and it says more complex scenarios may need channels or other synchronization. If that shape grows into frequent refreshes, avoid holding a contended lock during render, avoid letting background tasks mutate widget state that the UI also mutates, and guard stale results with a generation or request id. For many apps, a worker message that carries a snapshot is easier to reason about than shared mutable UI state.
  • No backpressure or shutdown policy. Templates often use unbounded channels and short cancellation sketches because they are small. Real producers can outpace the terminal, and terminal lifecycle bugs often appear during exit, suspend, resume, panic recovery, or child TUI handoff. Use bounded channels where rate matters, define what gets dropped or replaced, and make the terminal owner stop event reading before it restores or hands off the terminal.

These are not reasons to discard older examples. They are the rules to apply when an app based on those examples starts doing real async work: keep one terminal owner, keep Terminal::draw fast, drain bursts before drawing, and make background work cross the boundary as application messages.

The examples and templates below are source material, not verdicts. Some are Ratatui applications; some are Crossterm-based TUIs; Helix and Termina are adjacent terminal projects whose event and render models overlap with the same problems. Read each source in two passes: identify the mechanism it demonstrates, then audit terminal ownership, blocking, ordering, backpressure, and shutdown for your application.

The async-github example shows async data fetching with Ratatui. It keeps rendering in the UI loop and fetches GitHub pull requests in a background task, which demonstrates the core async fit: network work waits away from the terminal and reports a result back to the UI. The example is intentionally small, so treat it as a fetch pattern, not as a complete policy for repeated refreshes, cancellation, or backpressure.

The simple-async template shows a compact Tokio loop that polls EventStream and updates application state. The event-driven-async template adds a message boundary by wrapping crossterm events, ticks, and application events in an mpsc-based event handler. These teach event-stream mechanics and message routing. Once an app grows, keep the ownership rule from Async Applications: background work can be async, but terminal I/O should have one owner.

The crates-tui app shows a fuller async application than the templates. Its crates-tui app loop awaits one merged event, drains queued actions with try_recv, and draws only for render or resize actions. Its crates-tui event streams merge ticks, a key-chord refresh timer, a fixed render interval, and EventStream. Its worker paths show message passing around network work: crates-tui search tasks and crates-tui summary tasks spawn async requests, update shared Arc / Mutex state, and send actions back to the UI, while crates-tui detail tasks keep JoinHandles so stale detail requests can be aborted before starting a new one. The audit points are the places to harden if the same shape grows: App::run is still marked #[tokio::main], drawing still blocks the future that owns the terminal, the action channel is unbounded, search and summary requests are not guarded by a generation id, and the fixed render interval can still hide queued work if drawing becomes expensive.

Larger applications expose the machinery that short examples omit: ownership boundaries, burst draining, coalescing, backpressure, cancellation, terminal handoffs, and redraw scheduling.

Yazi shows an async-heavy Ratatui application with explicit cancellation, render flags, and queues around the blocking draw boundary. Its Yazi app loop uses tokio::select!, drains queued application events with try_recv, and schedules rendering from render flags. Its Yazi terminal wrapper owns terminal setup, sends terminal queries, creates an EventStream, and spawns a task to forward terminal events into the application event bus. Background work is split into worker classes with cancellation tokens in the Yazi worker scheduler. CPU-heavy highlighting uses spawn_blocking and a cancellation ticket in the Yazi highlighter, while Yazi preview tasks and Yazi search tasks abort stale JoinHandles and chunk bursty result streams. The boundary is that Yazi render path still calls Terminal::draw; the surrounding machinery keeps that blocking operation coordinated.

Codex CLI is a larger async Ratatui application with explicit terminal boundaries. Its Codex app loop selects over app messages, active thread events, terminal events, and app-server events. Its Codex event stream keeps one shared Crossterm stream and can drop and recreate the stream when the TUI must relinquish stdin. Its Codex frame scheduler coalesces redraw requests before notifying the UI loop, and its Codex terminal probes run short terminal queries only while the event stream is absent or paused. Its Codex terminal draw path also makes the blocking draw cost visible: autoresize, render, flush, cursor update, and backend flush happen inside the terminal owner.

bottom shows the synchronous-terminal-owner pattern. Its bottom startup loop creates a data collection thread, an input thread, and a cleaning thread, then the main loop receives application events and draws from one terminal owner. Its bottom input thread blocks in Crossterm polling and reading, filters key releases, ignores mouse motion, and rate-limits mouse scroll. That shape avoids starving a Tokio runtime with Terminal::draw. The remaining boundary is terminal queries and handoffs: input reading and rendering still happen on different threads, and the bottom update branch converts collected data on the UI thread before drawing.

gitui shows many blocking event sources feeding one central UI selector. Its gitui select loop waits on input, Git worker notifications, app notifications, a ticker, a file watcher, and a spinner ticker. Its gitui input thread shows a concrete terminal handoff pattern: suspend polling while an external editor owns the terminal, then resume polling and re-hide the cursor when control returns. Its gitui draw path resizes before drawing when the app requires a redraw, and its gitui async job worker keeps only one queued follow-up job while another job is running. The follow-up boundary is event batching: the app still has a separate input reader thread and still draws after many individual events.

bacon and dua-cli show blocking selector designs with producer pressure. Bacon’s bacon app loop selects across timers, file watchers, process output, and user events, while its bacon executor sleeps through a grace period before starting a command and reads child stdout and stderr on blocking threads. Dua’s dua input channel uses a zero-capacity channel for key events, its dua event loop selects between terminal input and background traversal events, and its dua traversal uses a bounded filesystem-walk channel. These sources shape producer behavior before it reaches the UI. The limitation is still frame cost: if process output or filesystem results arrive faster than the UI can integrate and draw them, the terminal owner can fall behind.

tokio-console shows compact state streaming inside a Tokio loop. Its tokio-console main loop selects over Crossterm input, instrumentation messages, and a bounded task-details channel, then draws after the selected branch updates state. Its tokio-console detail watcher uses tokio::sync::watch to stop stale detail streams when the selected task changes. Its tokio-console input module explicitly notes that supporting blocking input backends would probably involve spawn_blocking. The boundary is the same terminal cost: Terminal::draw stays in the async loop, so expensive frames still block that task.

Helix and Termina are not Ratatui applications, but they model the same terminal problems directly. Helix’s Helix render path keeps frame start, autoresize, render, and draw in the application loop. Async code calls request_redraw, which is debounced, and Helix’s diff worker batches document changes, uses block_in_place for expensive diffing, and coordinates render locks and timeouts. Termina’s Termina event enum matters because it treats keys, resize events, focus, paste, CSI, OSC, and DCS responses as one event model instead of pretending terminal replies are separate from input. Its filtered event reader lets poll and read keep rejected events buffered for later reads, and its Termina event stream adapts the blocking reader to async by parking a helper thread on the event source.

Read examples as design references, not verdicts. Short examples isolate one teaching point. Larger applications show the supporting machinery that teaching examples omit. Nontrivial apps add ownership boundaries, burst draining, coalescing, backpressure, cancellation, and terminal handoff code because spawn a task and EventStream do not settle those decisions by themselves.