How Ghostty Manages Screen Buffers and Scrollback Using PageList in Zig

Ghostty stores terminal output in a PageList—a doubly-linked list of fixed-size pages—and uses lightweight Pin cursors to enable O(1) random access, lock-free rendering, and efficient scrollback pruning without moving memory.

Ghostty is a GPU-accelerated terminal emulator written in Zig. Its screen buffer and scrollback system rely on a custom PageList implementation that balances memory efficiency with fast random access. This article examines how the engine manages terminal state using pages, pins, and lazy scrolling based on the source code in ghostty-org/ghostty.

What Is the PageList?

The PageList structure is defined in src/terminal/render.zig. It is essentially a std.DoublyLinkedList(Node) where each Node represents a page of terminal output. Rather than storing every character in a single contiguous array, Ghostty allocates fixed-size blocks (pages) that hold a configurable number of rows and columns.

Each page node contains:

  • A slice of Cell structures (character codepoint, attributes, and color)
  • Pointers to the next and previous nodes in the list
  • Metadata describing the height (row count) contained in that page

The list maintains metadata such as totalLines, cols (terminal width), and maxScrollback to enforce memory limits.

Core Abstractions: Pages and Pins

The Page Node

A Page is the atomic unit of storage. When the terminal emits a new line, Ghostty writes into the active page at the tail of the list. If the page reaches capacity, the system allocates a new Node and appends it via PageList.appendPage. This design avoids expensive reallocation of large buffers when the scrollback grows.

The Pin Cursor

Navigation through the list is handled by Pin, a lightweight cursor defined alongside PageList. A Pin stores:

  • A pointer to a specific Node (page)
  • A column and row offset within that page's buffer

Pins are used extensively by the renderer to mark the top-left corner of the viewport and by the search subsystem to remember match positions. Because pins are untracked when their referenced page is removed, they remain safe to hold even while the list mutates.

Writing New Output to the Buffer

When incoming data arrives, Ghostty obtains a mutable handle to the current write position using activeTailPin(). This returns a Pin pointing to the active tail node where new cells can be written.

// Initialize a PageList for an 80-column terminal with 100,000 line scrollback
var pages = try PageList.init(allocator, 80, 24, 100_000);

// Get a mutable pin at the bottom of the buffer
var write_pin = try pages.activeTailPin();

// Write text; if the page fills, a new node is automatically appended
try pages.writeLine(&write_pin, "Hello, Ghostty!");

// The pin automatically advances to the next free row

If the active page fills during the write operation, PageList transparently allocates a new page, links it to the tail, and updates the internal active_tail pointer. The writer continues without needing to know that a new allocation occurred.

Scrolling Without Memory Movement

Ghostty implements scrolling by moving Pin cursors rather than copying data. The viewport—the portion of the buffer currently visible on screen—is defined by two pins: one for the top-left corner and one for the bottom-right.

When a user scrolls up, the renderer simply calls methods on the viewport pin to walk backward through the linked list:

// Get the pin representing the top-left of the current viewport
var viewport = pages.viewportPin();

// Scroll up by 5 lines (move the viewport pin backward in the list)
try viewport.moveBack(5);

// Render the visible area starting from the new viewport position
const rows = try pages.pinRows(viewport, terminal_height);

Because this operation only adjusts pointer offsets within the Pin struct, scrolling is O(1) per page boundary crossed and never triggers a memmove. The underlying PageList remains unchanged, allowing concurrent readers (such as the search indexer) to continue operating safely.

Pruning Scrollback to Save Memory

To prevent unbounded memory growth, Ghostty enforces a maxScrollback limit (default 100,000 lines). After each write operation, the maybeTrim() method checks whether totalLines exceeds this threshold. If so, it removes pages from the head of the list until the limit is satisfied.

Before freeing a page, Ghostty calls untrackPins() to invalidate any live pins that reference the doomed node:

// Internal cleanup routine called automatically after writes
fn maybeTrim(self: *PageList) !void {
    while (self.totalLines > self.maxScrollback) {
        const old_head = self.pages.first orelse break;
        
        // Invalidate any pins pointing to this page (renderer, search, etc.)
        self.untrackPins(old_head);
        
        // Remove and free the page
        self.pages.remove(old_head);
        self.totalLines -= old_head.height;
    }
}

This mechanism ensures that dangling pointers cannot exist; any component holding a pin to a pruned page will find that pin cleared or updated to null, preventing use-after-free errors.

Searching Across the Buffer

The search subsystem in src/terminal/search/pagelist.zig provides a PageListSearch iterator that traverses the buffer using pins. Because searching is read-only, it can execute concurrently with the renderer without locks.

// Initialize a search starting from the top of the scrollback
var search = try PageListSearch.init(allocator, &pages, pages.headPin());

// Iterate through matches
while (search.next()) |match| {
    std.debug.print("Match at row {}, col {}\n", .{ match.start.row, match.start.col });
    
    // Advance the search pin to the next candidate position
    if (!search.feed()) break;
}

The search uses Pin navigation to move through the list, and because it never modifies the underlying nodes, it does not interfere with the main terminal's write operations or scrollback pruning.

Integration with the Rendering Pipeline

During each paint cycle defined in src/terminal/render.zig, the renderer caches the current viewport pins in its RenderState. It then calls pinRows() to obtain an array of pins representing each visible row, which are used to copy cell data into the GPU buffer.

// Inside the render loop
const start_pin = self.state.viewport_pin;
const row_pins = try self.pages.pinRows(start_pin, self.terminal_rows);

for (row_pins, 0..) |pin, i| {
    // Copy cells from this row into the GPU vertex buffer
    const cells = pin.node.buffer[pin.offset..][0..self.cols];
    self.uploadRow(i, cells);
}

This approach minimizes cache misses by touching only the pages currently in view, leaving cold scrollback pages untouched in memory.

Summary

  • PageList is a doubly-linked list of pages (nodes), each storing a block of terminal cells.
  • Pin cursors provide O(1) access to any position in the buffer and are automatically invalidated when their target page is pruned.
  • Scrolling is implemented by moving viewport pins backward or forward; no data is copied.
  • Scrollback management removes old pages from the list head once maxScrollback is exceeded, calling untrackPins() to ensure safety.
  • Rendering and search both operate on pins, enabling lock-free concurrent reads while the terminal writes new output.

Frequently Asked Questions

How does Ghostty handle infinite scrollback without running out of memory?

Ghostty does not offer infinite scrollback; it enforces a strict maxScrollback line limit configured at startup. When the total line count exceeds this limit, the maybeTrim() method in src/terminal/render.zig removes the oldest pages from the head of the PageList and frees their memory. This prevents unbounded allocation while preserving the most recent output.

What happens to search results when the scrollback buffer is trimmed?

When pages are removed from the head of the list, Ghostty calls untrackPins() to invalidate any Pin structures referencing those pages, including pins held by the search iterator. The search subsystem detects these invalidated pins and treats them as end-of-buffer conditions, silently dropping matches that scrolled off into freed memory.

Why does Ghostty use a linked list instead of a contiguous buffer for terminal content?

A linked list of pages allows Ghostty to prepend and append rows in O(1) time without copying existing data. When scrollback is pruned, only the head node is unlinked and freed; a contiguous buffer would require expensive memmove operations to maintain tight packing. Additionally, the Pin abstraction makes random access nearly as fast as array indexing while supporting safe concurrent reads.

How does the Pin cursor ensure thread safety during concurrent rendering?

Pin itself is not thread-safe, but Ghostty's architecture ensures that no thread mutates a page while another holds a pin to it. The renderer and search subsystems only read from pages via pins, while the terminal writing thread only appends to the tail or removes from the head. The untrackPins() mechanism guarantees that a page is never freed while a reader still references it, eliminating race conditions without requiring explicit locks on every access.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →