DEV Community

Cover image for I Built a Visualizer for Go's Garbage Collector
Timur Gilyazov
Timur Gilyazov

Posted on

I Built a Visualizer for Go's Garbage Collector

Go's garbage collector is one of those things that usually "just works". And that is a good thing: most of the time, you do not want to think about it.

Until a service starts slowing down under load, latency increases, and memory usage jumps.

At that point, you usually check the obvious things first: CPU, locks, network, pprof, application metrics. The garbage collector often does not come to mind immediately, even though it can absolutely be part of the performance story.

Gopher sitting in a trash can, used as an illustration for a Go garbage collector article

Go already gives us ways to observe the garbage collector from the outside. The runtime can print information about every GC cycle through gctrace and gcpacertrace, and it also exposes structured data through runtime/metrics.

The problem is that during a real run, this quickly turns into a stream of lines and numbers. One or two lines are fine. But understanding the overall picture is much harder:

  • Is GC running more often than before?
  • Are there long Stop-The-World pauses?
  • Is heap usage stabilizing or slowly growing?
  • Did a code change actually improve anything?

I wanted to see GC behavior not as scattered logs, but as a complete picture: how often collections happen, how heap live/goal values move, where STW pauses become unusual, and how one run differs from another.

That is why I built gcscope: a terminal visualizer for Go's garbage collector.

It collects data from gctrace, gcpacertrace, and runtime/metrics, shows live terminal charts, lets you save snapshots, and compares different runs.

gcscope UI GC metrics, charts, and details about recent garbage collection cycles in one terminal window

In this article I will show:

  • how to watch Go GC behavior in real time
  • how to check whether GC may be related to a performance drop
  • how to spot long STW pauses
  • how to reason about heap live vs heap goal
  • how to run visualization on your own Go binary without changing its code
  • how gcscope turns runtime logs into terminal charts
  • how to compare application behavior before and after a change
  • how to use this as a starting point before going deeper with pprof, trace, and other profiling tools

By the end, you should have a better mental model for observing Go's GC and a practical way to notice situations where it may affect application performance.

1. A few terms before we start

I do not want to turn this article into a deep dive into the entire Go runtime, but a few terms are useful.

  • GC cycle: one run of the garbage collector.
  • STW (Stop-The-World): a short pause in program execution needed by the garbage collector for some memory-management operations.
  • heap live: the amount of live heap memory after a GC cycle, meaning objects that are still reachable and needed by the program.
  • heap goal: the target heap size that the runtime uses when deciding when to start the next GC cycle.
  • gctrace: a runtime logging mode that prints information about every GC cycle to stderr. The exact format can differ between Go versions.
  • gcpacertrace: an additional logging mode that shows information about the GC pacer, the mechanism that regulates GC work and helps keep heap growth under control.
  • runtime/metrics: a standard library package that exposes structured runtime metrics without parsing text logs.

In this article I only talk about what can be observed from the outside through these sources. gcscope does not patch the runtime and does not claim to expose every internal detail of Go's GC. It is a way to look at the data Go already provides in a more useful form.

A deeper article about GC internals and runtime metrics would be a separate topic. Here, the focus is practical observation and visualization.

2. Why gctrace and gcpacertrace are useful, but hard to use dynamically

If you enable gctrace, the Go runtime starts printing information about every garbage collection cycle: pauses, heap sizes, GC CPU usage, and other values.

If you add gcpacertrace, you also get data about the pacer, the component that regulates GC intensity.

This is useful data. But when you try to use it during a real application run, a few problems appear quickly.

It is hard to see trends over time

One line is readable. A hundred or two hundred lines become noise.

From raw logs, it is not easy to answer questions like:

  • Is GC happening more frequently, or was that just one spike?
  • Where do long STW pauses appear?
  • Did heap live stabilize, or is it slowly growing?
  • Did the program behave differently after a code change?

The data is there, but the overall picture is not.

Before/after comparisons are painful

Imagine you changed GOGC, added a cache, rewrote an allocation-heavy path, or changed the workload. Now you want to know whether things got better or worse.

With raw logs, this becomes manual work: save the output of the first run, save the output of the second run, find comparable regions, compare values, and try not to get lost.

For a one-off deep investigation, that is possible. For fast iteration, it is inconvenient.

Distributions matter more than single values

A single STW pause of 300 microseconds does not say much by itself. Context matters:

  • Is this a normal value or a rare long pause?
  • What does p50 look like, the typical STW pause level?
  • What happens to p99, which helps surface rare long pauses?
  • What was the maximum STW pause in the recent observation window?
  • Did any of that change after the latest modification?

gctrace is not bad. Quite the opposite: it is one of the most useful sources of information about Go GC behavior. But logs are better for inspecting individual events than for understanding the whole dynamic picture.

3. Seeing Go GC behavior in one minute with gcscope

You can install gcscope with go install:

go install github.com/timur-developer/gcscope/cmd/gcscope@latest
Enter fullscreen mode Exit fullscreen mode

After that, you can use it as a regular CLI tool.

The fastest way to see the UI is to run a built-in demo workload:

gcscope lab churn
Enter fullscreen mode Exit fullscreen mode

Running the builtin lab churn demo

The lab mode does not require preparing a service or setting up a load test. The tool runs a synthetic workload for you, which is useful for seeing how the charts, STW pauses, heap changes, and other metrics behave.

If you start gcscope and see no updates, that does not automatically mean something is broken. Your program may simply not be hitting GC cycles yet. In demo mode, this is usually visible quickly; in a real application, it depends on allocations and workload.

4. What the UI shows and how to read it

It is easy to get distracted by charts and just stare at them. But gcscope becomes much more useful when you start with a question.

For example:

  • Why did GC start running more often?
  • Are there rare long STW pauses?
  • Is heap live growing?
  • How close is heap live to heap goal?
  • Did behavior change after a new version of the code?

This turns the UI from a nice picture into an analysis tool.

General view of the gcscope interface

The interface has several main areas:

  1. Current Values: current GC cycle number, latest STW pause, heap live, heap goal.
  2. Information: summary for recent events: GC frequency, max STW, thresholds, environment, snapshot status.
  3. STW per cycle: STW pauses for individual GC cycles.
  4. Cycle Details: details for the selected GC event.
  5. Heap live over time: how live heap changes over time.
  6. STW p50/p99/max over time: how STW window statistics change over time.

How often GC runs

The Information panel shows GC frequency and average interval between cycles.

These values are calculated over recent events. They help you understand whether GC is truly running more often or whether you are seeing a short random burst.

Frequent GC is not automatically a problem. But if it appears together with higher latency, extra CPU usage, or longer STW pauses, it is a reason to look deeper: memory allocations, GOGC, GOMEMLIMIT, and workload shape.

Where long STW pauses appear

Usually, developers do not notice STW pauses directly. They notice symptoms: the service feels jittery, some requests become slower, and the obvious cause is not clear.

In gcscope, these areas are especially useful:

  1. last STW (us) in Current Values, which shows the STW pause of the latest GC cycle.
  2. STW p50/p99/max over time (us), which shows how typical and rare pauses changed over time.
  3. The per-cycle bar chart, which lets you inspect individual events.

The basic idea is simple: p50 shows the normal background level, while p99 and max help surface rare long pauses.

If long pauses repeat, it is important to look not only at the pause value itself, but also at when it appeared. Does it match an allocation spike? A change in request pattern? A different runtime setting?

Heap behavior: heap live vs heap goal

Heap size is rarely interesting by itself. The dynamic behavior matters more:

  • Is heap live growing over time or stabilizing?
  • How close is live heap to heap goal?
  • How does this change under different workloads?
  • What happens after code or runtime-setting changes?

The pair heap live / heap goal helps you see how much pressure the garbage collector is under while keeping heap growth within target bounds.

In gcscope, this is visible in Current Values and on the Heap live over time chart.

What changed between two runs

When you change code, runtime settings, or workload shape, you usually want a quick answer: did this help, or did it make things worse?

gcscope gives you two ways to approach that:

  • visually compare behavior in the UI
  • save snapshots and compare them with diff

I will return to snapshots and diff later.

Minimal controls

For a first run, you only need a few keys:

  • ?, h, or f1: open help
  • space: pause or resume live updates
  • left / right: move through history when paused
  • s: save a snapshot
  • q or ctrl+c: quit

Animated demo of gcscope showing help, layout switching, chart zooming, pause mode, event history navigation, and snapshot saving

The GIF above shows basic interaction with gcscope: opening help, switching display modes, changing chart scale, pausing live updates, moving through event history, and saving a snapshot.

5. run mode: observing your own application without changing code

For most situations, I would start with run mode.

It starts your Go binary under observation and reads data that the runtime writes to stderr through gctrace and gcpacertrace.

Example:

gcscope run ./path/to/your-binary
Enter fullscreen mode Exit fullscreen mode

There are two important details.

First, target is a path to an already compiled binary, not a .go file. So build your application first:

# replace ./cmd/myapp with the path to your application's main package
go build -o ./myapp ./cmd/myapp
Enter fullscreen mode Exit fullscreen mode

Then run it through gcscope:

gcscope run ./myapp
Enter fullscreen mode Exit fullscreen mode

Second, if your application needs arguments, use -- as a separator:

gcscope run ./myapp -- --config ./config.yaml --port 8080
Enter fullscreen mode Exit fullscreen mode

Everything after -- is passed to your program unchanged. gcscope uses the separator to distinguish its own arguments from the target application's arguments.

6. Architecture: from stderr to TUI

At a high level, gcscope works the same way with any data source: it receives information about GC behavior, converts it into a stream of events, builds aggregates over those events, and sends the result to the UI.

For run mode, the path looks like this:

Go binary
  -> stderr (gctrace/gcpacertrace)
  -> parser
  -> GC events
  -> latest N events
  -> statistics and chart data
  -> terminal UI, snapshots, and run comparison
Enter fullscreen mode Exit fullscreen mode

Why run can see GC at all

For run mode to observe the garbage collector, the target process must output gctrace and gcpacertrace data.

gcscope does this automatically by configuring GODEBUG and adding gctrace=1 and gcpacertrace=1.

The important part is not to overwrite the user's existing GODEBUG settings. If GODEBUG already contains other options, they should be preserved and only the missing values should be added.

Here is the relevant code that builds the final GODEBUG value:

Code: building the final GODEBUG value
// internal/source/runner/runner.go
func NormalizeGODEBUG(value string) string {
    parts := strings.Split(value, ",")
    out := make([]string, 0, len(parts)+2)
    foundGctrace := false
    foundGcpacer := false

    for _, part := range parts {
        part = strings.TrimSpace(part)
        if part == "" {
            continue
        }

        switch {
        case strings.HasPrefix(part, "gctrace="):
            if !foundGctrace {
                out = append(out, "gctrace=1")
                foundGctrace = true
            }
        case strings.HasPrefix(part, "gcpacertrace="):
            if !foundGcpacer {
                out = append(out, "gcpacertrace=1")
                foundGcpacer = true
            }
        default:
            out = append(out, part)
        }
    }

    if !foundGctrace {
        out = append(out, "gctrace=1")
    }
    if !foundGcpacer {
        out = append(out, "gcpacertrace=1")
    }

    return strings.Join(out, ",")
}
Enter fullscreen mode Exit fullscreen mode

So the user starts their binary through gcscope, and the tool creates the conditions needed for the runtime to expose GC data.

Why a GC event is not just one log line

When designing a tool like this, the first idea seems simple: take a gctrace line, parse it with a regular expression, and immediately send values to the UI.

For a minimal prototype, that is enough. But limitations appear quickly.

First, the UI almost never needs the original log line. It needs values: GC number, time, STW pause, heap sizes, heap live / heap goal, whether GC was forced, and other fields.

Second, not all information comes from the same line. A gc ... line describes the GC cycle itself, while pacer: ... lines add information about the pacer. If the UI should show this as one event, those pieces need to be connected.

This is the parser entry point that separates GC lines from pacer lines:

Code: parsing GC and pacer trace lines
// internal/source/runner/parser.go
func (p *Parser) ParseLine(line string) (*domain.GCEvent, error) {
    trimmed := strings.TrimSpace(line)
    if trimmed == "" {
        return nil, nil
    }
    if strings.HasPrefix(trimmed, "gc ") {
        return p.parseGCLine(trimmed)
    }
    if strings.HasPrefix(trimmed, "pacer:") {
        return nil, p.parsePacerLine(trimmed)
    }
    return nil, nil
}

func (p *Parser) Flush() *domain.GCEvent {
    if p.current == nil {
        return nil
    }
    event := p.current
    p.current = nil
    return event
}
Enter fullscreen mode Exit fullscreen mode

Third, the tool needs aggregates on top of events: p50/p99/max over a sliding window, GC frequency, history for charts, snapshots, and diff. Doing all that over raw log lines would be awkward.

So I did not bind the UI directly to gctrace strings. Regular expressions can exist inside the parser, but the parser should return proper GC events.

The model became simple:

logs and metrics -> GC events -> aggregates -> charts, snapshots, diff
Enter fullscreen mode Exit fullscreen mode

Because of this, the UI does not know how the original stderr line looked. It only sees prepared data: GC cycle, STW pause, heap live/goal, and optional pacer fields.

How data reaches the UI

For the terminal interface, I use the message model from Bubble Tea, a Go library for building TUI applications.

Once a GC event is produced, gcscope sends it into the Bubble Tea model:

Code: sending GC events into the TUI model
// cmd/gcscope/lab.go, similar in run.go
go func() {
    for ev := range r.Events() {
        prog.Send(ui.GCEventMsg{
            Event: ev,
            At:    time.Now(),
        })
    }
}()
Enter fullscreen mode Exit fullscreen mode

When the UI receives a GC event, it updates the sliding window, recalculates aggregates, and refreshes chart history:

Code: updating aggregates and chart history
// internal/ui/model_update.go
case GCEventMsg:
    m.lastUpdate = msg.At
    m.now = msg.At

    m.store.Add(msg.Event)                         // sliding window of recent events
    m.agg = domain.ComputeAggregates(m.store.Recent()) // aggregate calculation
    m.pushHistory(msg.At)                          // history for charts

    if !m.paused {
        m.cursor = m.currentWindowLen() - 1
    }
    return m, nil
Enter fullscreen mode Exit fullscreen mode

The UI works with events, not raw log strings.

The UI works with a GCEvent model that contains the fields needed for charts, snapshots, and comparisons:

Code: GCEvent fields used by the UI
// internal/domain/events.go
type GCEvent struct {
    GCNum           int
    TimeSinceStartS float64
    GCCPUPercent    float64

    HeapStartMB int
    HeapEndMB   int
    HeapLiveMB  int
    HeapGoalMB  int

    // ...
    // other fields from gctrace/runtime metrics
}
Enter fullscreen mode Exit fullscreen mode

That makes the rest of the logic much easier: charts, windows, p50/p99/max, snapshots, and diff all build on the same data model.

7. attach mode: observing an already running application

attach mode is useful when you want to observe an already running application through an HTTP endpoint. Your service exposes runtime metrics, and gcscope polls that endpoint, converts the results into events, and shows them in the UI.

The flow is:

  1. Add an HTTP endpoint from pkg/reporter, a small package inside gcscope that exposes selected runtime/metrics data as JSON.
  2. gcscope periodically polls the endpoint and converts metrics into events.
  3. The UI works with those events just like it does in other modes.

The minimal example below uses the standard library's http.ServeMux. That is not a requirement. In your project, you can register the handler in any router you already use, such as chi, gorilla/mux, or another router.

reporter.New() returns an object with two methods:

  • Path(): the endpoint path, /gcscope/metrics by default
  • Handler(): an HTTP handler that returns runtime/metrics data in JSON format

Minimal example:

package main

import (
    "log"
    "net/http"

    "github.com/timur-developer/gcscope/pkg/reporter"
)

func main() {
    rep := reporter.New()

    mux := http.NewServeMux()
    mux.Handle(rep.Path(), rep.Handler())

    log.Fatal(http.ListenAndServe(":8080", mux))
}
Enter fullscreen mode Exit fullscreen mode

Then attach gcscope to the endpoint. If the service runs locally:

gcscope attach http://127.0.0.1:8080/gcscope/metrics
Enter fullscreen mode Exit fullscreen mode

The exact JSON contract is not the important part for this article. The useful point is that the format is provided by pkg/reporter, while the original source remains runtime/metrics.

If you want to look deeper, the implementation and package README are in the repository: pkg/reporter.

8. run vs attach: why have both?

The two modes solve a similar problem: observing garbage collector behavior. But they get data differently.

  • run parses gctrace / gcpacertrace output from the target process's stderr
  • attach reads runtime/metrics through an HTTP endpoint

This leads to two important differences.

First, attach does not have access to the target process environment. Values such as GOGC, GOMEMLIMIT, and GODEBUG are unavailable and shown as n/a in the UI.

Second, values in attach and run do not have to match one-to-one. They come from different data sources, with different precision and semantics.

In practice, the choice depends on the task:

  • If you want the closest view to gctrace for individual GC cycles and STW pauses, start with run.
  • If you want to connect to an already running process through an endpoint, use attach.

9. Data storage, snapshots, and diff

gcscope keeps the latest N garbage collection events in memory. By default, the window size is 200 events, but you can change it with --window-size.
The observation window size comes from configuration and is used when creating the UI store:

Code: configuring the GC event window
// cmd/gcscope/run.go: pass the window size from config (--window-size)
model := ui.NewModel(ctx, cancel, cfg.WindowSize, snapshotDir, writer, stwTh, envInfo)

// internal/ui/model_types.go: inside the model, create a store with the latest N events
store: domain.NewStore(windowSize),
Enter fullscreen mode Exit fullscreen mode

This is intentional.

GC is a stream of similar events. For interactive analysis, the entire process history is often less useful than the last few minutes or the latest N cycles.

A sliding window helps:

  • keep the UI responsive
  • recalculate p50/p99/max quickly
  • show what the garbage collector is doing recently

A snapshot in gcscope is a JSON file that saves the current observation window.

Here is what part of a snapshot file looks like:

{
  "version": 1,
  "current": {
    "gc_cycles_total": 16,
    "last_stw_us": 0,
    "heap_live_mb": 59,
    "heap_goal_mb": 166
  },
  "window": {
    "stw_p50_us": 0,
    "stw_p99_us": 550,
    "stw_max_us": 550
  },
  "events": [
    {
      "gc_num": 1,
      "time_since_start_s": 0.08,
      "heap_live_mb": 3,
      "heap_goal_mb": 4
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

It contains:

  • current values such as gc_cycles_total, last_stw_us, heap_live_mb, heap_goal_mb
  • window statistics such as stw_p50_us, stw_p99_us, stw_max_us
  • the list of recent GC events from the same window used by the UI

In practice, snapshots are useful after runs you want to compare:

  • before and after an optimization
  • before and after changing GOGC
  • before and after deploying a new service version
  • under different workload scenarios

Comparison is done with gcscope diff. The first argument is the "before" snapshot, and the second is the "after" snapshot.

gcscope diff ./before.json ./after.json
Enter fullscreen mode Exit fullscreen mode

For example:

gcscope diff \
  gcscope/tmp/snapshots/gcscope-snapshot-2026-05-28T15-14-22.json \
  gcscope/tmp/snapshots/gcscope-snapshot-2026-05-28T15-16-58.json
Enter fullscreen mode Exit fullscreen mode

diff compares the main heap values and STW window statistics, then prints the difference as B - A.

Example output:

A:
  gc_cycles_total: 16
  heap_live_mb:    59
  stw_max_us:      550
  stw_p50_us:      0
  stw_p99_us:      550

B:
  gc_cycles_total: 56
  heap_live_mb:    9
  stw_max_us:      590
  stw_p50_us:      0
  stw_p99_us:      590

Delta (B-A):
  heap_live_mb: -50
  stw_max_us:   +40
  stw_p50_us:   0
  stw_p99_us:   +40
Enter fullscreen mode Exit fullscreen mode

This is not an automatic leak detector and not a magic optimizer. But it is a fast way to answer a practical question: did the change affect GC behavior, and in which direction?

10. Where gcscope is especially useful

I see gcscope as a quick first step when investigating problems that might be related to GC.

When an application starts behaving strangely, it is not always obvious where to look first: runtime settings, workload shape, network, scheduler, or application code.

gcscope helps check one hypothesis quickly: what was the garbage collector doing at that moment?

It shows GC behavior over time:

  • how often GC runs
  • what happens to the heap
  • whether long STW pauses appear
  • whether behavior changed after code or runtime-setting changes

If the charts show something suspicious, it becomes easier to choose the next tool: open pprof, inspect go tool trace, find allocation-heavy paths, or compare the data with Prometheus and Grafana metrics.

11. Trying gcscope on your own project

The simplest way to try gcscope on your project is:

  1. Install gcscope.
  2. Run the built-in lab churn workload to understand the UI.
  3. Build your Go application or service as a binary.
  4. Run it with gcscope run under a realistic workload.
  5. Save a snapshot with s.
  6. Repeat the run after changing code or runtime settings and save a second snapshot.
  7. Compare both snapshots with gcscope diff.

Minimal command set:

go install github.com/timur-developer/gcscope/cmd/gcscope@latest

gcscope lab churn

# replace ./cmd/myapp with the path to your application's main package
go build -o ./myapp ./cmd/myapp

gcscope run ./myapp

gcscope diff ./before.json ./after.json
Enter fullscreen mode Exit fullscreen mode

The project code, installation instructions, and documentation for gcscope are here:

https://github.com/timur-developer/gcscope

If the tool looks useful, I would really appreciate a GitHub star, feedback in GitHub Issues, or your thoughts in the comments.

How do you usually investigate a Go service that slows down under load? Do you start with pprof, metrics, logs, traces, or something else? And at what point do you check the garbage collector?

Top comments (0)