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.
: 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.
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 livegrowing? - How close is
heap livetoheap goal? - Did behavior change after a new version of the code?
This turns the UI from a nice picture into an analysis tool.
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
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
Then run it through gcscope:
gcscope run ./myapp
Second, if your application needs arguments, use -- as a separator:
gcscope run ./myapp -- --config ./config.yaml --port 8080
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
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, ",")
}
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
}
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
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 .
8. run vs attach: why have both?
The two modes solve a similar problem: observing garbage collector behavior. But they get data differently.
runparsesgctrace/gcpacertraceoutput from the target process'sstderr
attachreadsruntime/metricsthrough 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
gctracefor individual GC cycles and STW pauses, start withrun. - 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),
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
}
]
}
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
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
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
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:
- Install
gcscope. - Run the built-in
lab churnworkload to understand the UI. - Build your Go application or service as a binary.
- Run it with
gcscope rununder a realistic workload. - Save a snapshot with
s. - Repeat the run after changing code or runtime settings and save a second snapshot.
- 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
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?
SOCIAL SHARE CARD GENERATOR