# How Ghostty Manages Screen Buffers and Scrollback Using PageList in Zig

> Learn how Ghostty leverages PageList for efficient screen buffer and scrollback management. Discover O(1) random access and lock-free rendering.

- Repository: [Ghostty/ghostty](https://github.com/ghostty-org/ghostty)
- Tags: internals
- Published: 2026-05-01

---

**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`](https://github.com/ghostty-org/ghostty/blob/main/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.

```zig
// 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:

```zig
// 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:

```zig
// 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`](https://github.com/ghostty-org/ghostty/blob/main/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.

```zig
// 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`](https://github.com/ghostty-org/ghostty/blob/main/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.

```zig
// 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.