~
Lessons from shipping three Bubble Tea apps
all writing
·
  • #go
  • #tui
  • #bubble tea
  • #cli
  • #open source

Two Years and Three TUIs Later: What Shipping Bubble Tea Apps Taught Me

What shipping three production Bubble Tea apps taught me about architecture, state machines, performance, and the surprises of real users.

Two years ago, I wrote a starry-eyed guide to building CLI tools with Bubble Tea. It walked through the Elm Architecture, showed a styled todo app, and ended with a tidy list of best practices. I still stand behind that post. But it was the equivalent of teaching someone to paint by having them copy a still life.

Since then, I’ve shipped three TUI applications: ports, a live-refreshing view of listening ports with vim keybindings and process management. envdiff, a tool for comparing, validating, and syncing .env files across environments, complete with a matrix view and interactive sync TUI. And restless, a full terminal HTTP client built around .http files, with imports from Postman/Insomnia/Bruno/curl/OpenAPI, code generation in 8 languages, response assertions, and ES5.1 scripting.

Each of these taught me something the todo-app tutorial couldn’t. This post is what I wish I’d known before starting.

Architecture at Scale: When Update Melts

The first version of ports had a single Update function handling every message type. Keyboard input, window resizing, timer ticks for auto-refresh, results from shelling out to lsof, sort commands, filter state changes, kill confirmations. It worked for about a week.

The problem isn’t Bubble Tea’s architecture. The Elm Architecture is sound. The problem is that a single Update function with 20+ message types becomes a switch statement that scrolls for screens. You lose the ability to reason about state transitions because everything touches everything.

Component composition is the answer, but it takes some deliberate work. Here’s the pattern I landed on across all three tools:

// Each component owns its own Update and View.
// The root model routes messages to the right component.

type Model struct {
    table    tableModel     // port listing
    filter   filterModel    // search/filter bar
    detail   detailModel    // expanded process info
    status   statusModel    // bottom status bar
    focus    component      // which component has focus
    width    int
    height   int
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd

    // Global keys handled at root level
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "tab":
            m.focus = m.focus.Next()
            return m, nil
        case "ctrl+c", "q":
            return m, tea.Quit
        }
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        // Distribute size to children
    }

    // Route message to focused component
    switch m.focus {
    case focusTable:
        newTable, cmd := m.table.Update(msg)
        m.table = newTable
        cmds = append(cmds, cmd)
    case focusFilter:
        newFilter, cmd := m.filter.Update(msg)
        m.filter = newFilter
        cmds = append(cmds, cmd)
    }

    return m, tea.Batch(cmds...)
}

The key insight: components don’t need to implement tea.Model. They just need their own Update and View methods. The root model is a coordinator, not a god object.

tea.Cmd Discipline: Never Block Update

The freeze bug in ports’ first version was a classic. The Update function called exec.Command("lsof", ...) synchronously. While lsof ran (sometimes 200ms on a busy system), the entire TUI froze. No key input, no screen updates. It looked broken.

The fix is simple but easy to forget: always do I/O in a tea.Cmd, never in Update:

// WRONG: blocks the whole TUI
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    // ...
    output, _ := exec.Command("lsof", "-iTCP", "-sTCP:LISTEN", "-P").Output()
    m.ports = parseLsof(output)
    return m, nil
}

// RIGHT: non-blocking
type portsRefreshedMsg struct {
    ports []Port
    err   error
}

func refreshPorts() tea.Cmd {
    return func() tea.Msg {
        output, err := exec.Command("lsof", "-iTCP", "-sTCP:LISTEN", "-P").Output()
        if err != nil {
            return portsRefreshedMsg{err: err}
        }
        return portsRefreshedMsg{ports: parseLsof(output)}
    }
}

This seems obvious in retrospect. But when you’re deep in a feature and “just need to read a file real quick,” it’s tempting to skip the indirection. Don’t. Every synchronous I/O call in Update is a future freeze bug.

State Machines Beat Boolean Soup

By the time restless had a request editor, a response viewer, a headers panel, an environment selector, and a vim-style command bar, the model had accumulated nine boolean flags: isEditing, showHeaders, showResponse, commandMode, insertMode, showEnvSelector, confirmDelete, showHelp, showDiff.

Nine booleans means 512 possible states. Most of those combinations are nonsensical. What does it mean for isEditing and showHelp and commandMode to all be true simultaneously? Nothing good.

The fix was to model the UI as an explicit state machine:

type AppMode int

const (
    ModeNormal AppMode = iota
    ModeInsert
    ModeCommand
    ModeEnvSelect
    ModeHelp
    ModeDiff
    ModeConfirmDelete
)

type Model struct {
    mode     AppMode
    previous AppMode  // for "press Esc to go back"
    // ...
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch m.mode {
        case ModeNormal:
            return m.handleNormalMode(msg)
        case ModeInsert:
            return m.handleInsertMode(msg)
        case ModeCommand:
            return m.handleCommandMode(msg)
        // ...
        }
    }
    return m, nil
}

This maps directly to how vim works, which makes sense because restless uses vim-style commands. Each mode has a clear set of valid transitions and a clear set of key bindings. You can look at handleNormalMode and know exactly what keys do what, without worrying about which combination of booleans might be set.

The lesson applies broadly: if your TUI has more than three boolean flags controlling UI state, stop and draw a state machine diagram. Then implement that diagram literally.

Performance: Not All Renders Are Created Equal

envdiff’s matrix view can show hundreds of rows across multiple .env files side by side. The first implementation re-rendered the entire table on every keystroke. Scrolling through a matrix of 400 rows across 5 files felt laggy, with visible flicker as the terminal redrew everything.

Lesson 1: Only render what’s visible. Bubble Tea’s viewport component helps here, but the core principle applies everywhere. If you have 400 rows and the terminal shows 30, don’t format 400 rows of styled strings on every render. Calculate the visible window, format those rows, skip the rest.

func (m Model) View() string {
    var b strings.Builder

    // Only render visible rows
    start := m.scrollOffset
    end := min(start+m.viewportHeight, len(m.rows))

    for i := start; i < end; i++ {
        b.WriteString(m.renderRow(i))
        b.WriteByte('\n')
    }

    return b.String()
}

Lesson 2: Cache expensive style computations. Lipgloss’s Render method isn’t free. If you’re styling the same strings repeatedly (column headers, status bar text that hasn’t changed), cache the rendered output and only recompute when the underlying data changes.

Lesson 3: Profile before you guess. Go’s built-in profiler works on TUI apps. I wasted an afternoon optimizing string concatenation in the matrix view when the actual bottleneck was Lipgloss width-calculation on wide Unicode characters. The profiler found it in 30 seconds. go tool pprof doesn’t care that your program has a TUI; it profiles just the same.

Testing TUIs: It’s Possible, I Promise

Testing a TUI feels impossible until you learn the patterns. The Bubble Tea ecosystem includes teatest, which lets you write automated tests that send messages to a model and assert on the resulting view.

func TestFilterReducesRows(t *testing.T) {
    m := initialModel()
    m.ports = []Port{
        {Number: 3000, Process: "node"},
        {Number: 8080, Process: "java"},
        {Number: 5432, Process: "postgres"},
    }

    // Simulate typing a filter
    m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
    m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n', 'o', 'd', 'e'}})

    view := m.View()
    if !strings.Contains(view, "node") {
        t.Error("filtered view should contain 'node'")
    }
    if strings.Contains(view, "postgres") {
        t.Error("filtered view should not contain 'postgres'")
    }
}

But the real trick is extracting pure logic out of Update. Parsing, filtering, sorting, formatting: all of these can be pure functions that take data in and return data out, with no Bubble Tea dependency. Those functions are trivial to test with standard Go table-driven tests.

// Pure function, easy to test
func filterPorts(ports []Port, query string) []Port {
    if query == "" {
        return ports
    }
    var result []Port
    for _, p := range ports {
        if strings.Contains(strings.ToLower(p.Process), strings.ToLower(query)) ||
            strings.Contains(strconv.Itoa(p.Number), query) {
            result = append(result, p)
        }
    }
    return result
}

func TestFilterPorts(t *testing.T) {
    ports := []Port{
        {Number: 3000, Process: "node"},
        {Number: 8080, Process: "java"},
    }
    filtered := filterPorts(ports, "node")
    if len(filtered) != 1 || filtered[0].Process != "node" {
        t.Errorf("expected 1 result for 'node', got %d", len(filtered))
    }
}

Golden file tests work beautifully for View output. Capture a known-good render, commit it, and fail the test if the output changes unexpectedly. This catches visual regressions that unit tests miss entirely. You’d be surprised how often a “minor” Lipgloss update subtly shifts padding.

The CI story is simpler than you’d think. TUI tests run headlessly in GitHub Actions without any special setup. No xvfb, no display server. Bubble Tea’s test mode handles everything in-process.

Distribution Reality

Writing a Bubble Tea app and shipping it to actual users are separated by more work than I expected.

Homebrew taps. All three tools are available via brew install shahadulhaider/tap/<tool>. Setting up a Homebrew tap repo is a one-time cost (it’s just a GitHub repo with Ruby formula files). But keeping formulas updated with each release, handling sha256 checksums for multiple architectures, and testing on both Intel and Apple Silicon Macs adds friction to every release.

goreleaser automates the painful parts: cross-compilation for macOS (amd64 + arm64), Linux (amd64 + arm64), and Windows. It generates checksums, creates GitHub releases, and updates the Homebrew formula. The config is about 80 lines of YAML and saves hours per release. Worth setting up on day one, not day 30.

The surprise of actual users. I shipped ports expecting maybe 10 people to try it. Then someone filed an issue about Windows Terminal’s cursor rendering. Another person reported a crash on an ancient version of alacritty. A third asked for Fish shell completions. Real users run real terminals, and “works on iTerm2 and Ghostty” covers about 60% of the terminal universe, generously.

My advice: test on at least three terminals (your daily driver, the macOS default Terminal.app, and one Linux terminal like kitty or alacritty). Add a --version flag from day one. When someone files a bug report, the first thing you’ll want is the version they’re running.

Would I Still Choose Bubble Tea?

Yes. With caveats.

Bubble Tea’s Elm Architecture is genuinely good for stateful, interactive UIs. The component model, once you learn to use it properly, scales to complex applications. The Charmbracelet ecosystem (Lipgloss for styling, Bubbles for common components, Huh for forms) provides real building blocks that save weeks of work. And Go’s cross-compilation story is unmatched for CLI distribution.

The caveats: the learning curve isn’t the framework, it’s the paradigm. Developers coming from imperative UI code (React developers excepted) struggle with the message-passing model at first. Debugging state issues requires understanding the entire message flow, which can be opaque when you have five components and 15 message types. Bubble Tea’s documentation, while improving, still has gaps that source code reading has to fill.

If your tool is simple (fewer than five interactive states), consider a simpler library. Charmbracelet’s huh library handles forms and prompts beautifully without the full Elm Architecture overhead. For complex, multi-view, keyboard-driven applications, Bubble Tea is still the best option in Go. Nothing else comes close.

Lessons

Two years and three TUIs later, here’s my short list:

  1. Compose components early. A single Update function with 20 message types is a refactor waiting to happen. Break it up before it breaks you.

  2. State machines, not booleans. If your mode logic uses more than three flags, model the modes explicitly. Draw the diagram. Implement the diagram.

  3. Never block Update. Every synchronous I/O call is a freeze bug. Use tea.Cmd for everything that touches the outside world.

  4. Render only what’s visible. Large datasets need virtual scrolling. Profile before you optimize, because the bottleneck is never where you think it is.

  5. Extract pure logic. The most testable part of your TUI is the part that doesn’t depend on Bubble Tea. Filters, parsers, formatters, validators: make them pure functions.

  6. Ship with --version, Homebrew, and three-terminal testing. You’ll need all of these sooner than you think.

  7. Real users will surprise you. In the best way. And sometimes in the “I didn’t know that terminal emulator existed” way.

The tutorial taught me Bubble Tea. Shipping taught me everything else.