Overview
Bubble Tea provides several performance optimization features to ensure smooth rendering and efficient resource usage in terminal applications.
Frame Rate Control
WithFPS Option
Control the maximum frames per second (FPS) for your application using the WithFPS option:
package main
import (
tea "github.com/charmbracelet/bubbletea"
)
func main() {
p := tea.NewProgram(
initialModel(),
tea.WithFPS(30), // Set to 30 FPS
)
p.Run()
}
FPS Constraints
Bubble Tea enforces FPS limits to balance performance and responsiveness.
- Default FPS: 60 frames per second
- Minimum FPS: Values less than 1 default to 60 FPS
- Maximum FPS: 120 frames per second (hard cap)
From renderer.go:13-14:
const (
defaultFPS = 60
maxFPS = 120
)
How FPS Works
The FPS setting controls the rendering ticker interval:
// From tea.go:1372-1379
func (p *Program) startRenderer() {
framerate := time.Second / time.Duration(p.fps)
if p.ticker == nil {
p.ticker = time.NewTicker(framerate)
} else {
p.ticker.Reset(framerate)
}
// ...
}
The renderer flushes updates at each ticker interval, ensuring consistent frame timing.
Choosing the Right FPS
30 FPS
Good for simple applications, status displays, and reduced CPU usage
60 FPS
Default - smooth for most interactive applications
120 FPS
Maximum smoothness for animations and high-frequency updates
package main
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
animation int
fps int
}
func (m model) Init() tea.Cmd {
return tick()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
m.animation++
return m, tick()
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
}
return m, nil
}
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(time.Millisecond*50, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) View() tea.View {
return tea.NewView(fmt.Sprintf("Frame: %d", m.animation))
}
func main() {
// For smooth animations, use higher FPS
p := tea.NewProgram(
model{fps: 60},
tea.WithFPS(60),
)
p.Run()
}
Higher FPS increases CPU usage. Choose the lowest FPS that provides acceptable smoothness for your use case.
Cell-Based Rendering
Bubble Tea uses cell-based rendering for efficient terminal updates:
Screen Buffer
The cursedRenderer uses a screen buffer to track terminal cells:
type cursedRenderer struct {
w io.Writer
buf bytes.Buffer // Updates buffer
scr *uv.TerminalRenderer
cellbuf uv.ScreenBuffer // Cell-based screen buffer
lastView *View
width, height int
// ...
}
How It Works
- View generation: Your
View() method returns content
- Cell parsing: Content is parsed into cells (characters + attributes)
- Diff calculation: New cells are compared with previous frame
- Minimal updates: Only changed cells are sent to the terminal
- Cursor optimization: Cursor movements use optimal escape sequences
Cell-based rendering means you only pay for what changes between frames, not the entire screen.
Rendering Optimizations
Synchronized Updates
Bubble Tea supports ANSI synchronized output mode (mode 2026) for tear-free rendering:
// From tea.go:1089-1095
if !p.disableRenderer && shouldQuerySynchronizedOutput(p.environ) {
// Query for synchronized updates support
p.execute(ansi.RequestModeSynchronizedOutput +
ansi.RequestModeUnicodeCore)
}
When supported by the terminal, this prevents partial frames from being visible.
Cursor Movement Optimizations
The renderer can use optimized cursor movements:
type cursedRenderer struct {
hardTabs bool // Use hard tabs for horizontal movement
backspace bool // Use backspace for backward movement
// ...
}
These optimizations reduce the number of bytes sent for cursor positioning.
1. Minimize View Complexity
func (m model) View() tea.View {
// Pre-compute expensive operations
content := m.cachedContent
return tea.NewView(content)
}
2. Cache Rendered Content
type model struct {
data []string
cachedView string
dataDirty bool
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dataUpdateMsg:
m.data = msg.newData
m.dataDirty = true // Mark cache as stale
}
return m, nil
}
func (m model) View() tea.View {
if m.dataDirty {
// Rebuild view only when data changes
m.cachedView = renderData(m.data)
m.dataDirty = false
}
return tea.NewView(m.cachedView)
}
3. Reduce Update Frequency
type model struct {
lastUpdate time.Time
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case frequentMsg:
// Throttle high-frequency updates
if time.Since(m.lastUpdate) < 100*time.Millisecond {
return m, nil // Skip this update
}
m.lastUpdate = time.Now()
// Process update...
}
return m, nil
}
4. Use Appropriate FPS
Static Content
Animations
High Performance
// For mostly static displays
tea.NewProgram(model{}, tea.WithFPS(30))
// For smooth animations
tea.NewProgram(model{}, tea.WithFPS(60))
// For maximum smoothness
tea.NewProgram(model{}, tea.WithFPS(120))
Enable Tracing
Bubble Tea supports tracing via the TEA_TRACE environment variable:
TEA_TRACE=/tmp/bubbletea.log ./myapp
This logs internal operations including render timing.
import (
"time"
"log"
)
func (m model) View() tea.View {
start := time.Now()
defer func() {
duration := time.Since(start)
if duration > 16*time.Millisecond { // 60 FPS threshold
log.Printf("Slow view render: %v", duration)
}
}()
content := buildView(m)
return tea.NewView(content)
}
FPS vs CPU Usage
| FPS | Frame Time | CPU Impact | Use Case |
|---|
| 30 | ~33ms | Low | Status displays, logs |
| 60 | ~16ms | Medium | Interactive apps (default) |
| 120 | ~8ms | High | Smooth animations |
Terminal Update Costs
Sending the entire screen content every frame. Expensive and unnecessary with cell-based rendering.
Only updating changed cells. Used by Bubble Tea - efficient for most use cases.
Atomic frame updates prevent tearing. Slight overhead but better visual quality.