Repository: arl/statsviz

Issue:
statsviz.TimeSeries.GetValue is called multiple times per second
Open 2 or more pages and you will see the phenomenon

code like this:

```golang
statsviz.NewServer(statsviz.TimeseriesPlot(func() statsviz.TimeSeriesPlot {
        plot, _ := statsviz.TimeSeriesPlotConfig{
            Name:  "testCall",
            Title: "testCall",
            Type:  statsviz.Scatter,
            Series: []statsviz.TimeSeries{{
                Name:     "call",
                GetValue: func() float64 {
                    println("call",time.Now().Format(time.DateTime))
                    return float64(0)
                },
            }},
        }.Build()
        return plot
    }()))
```

Here's the log with 3 pages opened
![image](https://github.com/arl/statsviz/assets/43053461/5e5b6956-abef-4a9b-ba16-8d390ac3c694)


Hints from issue comments:
@arl: Currently statsviz creates a new connection for each client/browser that connects to it. 
For each connection there's a once per second timer that gets the stats.

The orignal use case wasn't to have more than one client. 
Ok that's a bug.
Thanks for reporting!@AlekSi: I was sure it was a feature and built our own locking around it 😆 @arl: Bugs, features... That's just different point of views 😅😅

But yes I think that statsviz should be restructured so that there's a single timer for N client connections. Shouldn't be too hard 

Solution patch:
diff --git a/.github/workflows/assets.yml b/.github/workflows/assets.yml
index 7bf5657..c607a9c 100644
--- a/.github/workflows/assets.yml
+++ b/.github/workflows/assets.yml
@@ -1,5 +1,5 @@
 name: Assets
-on: [push, pull_request]
+on: [pull_request]
 jobs:
   check-assets:
     runs-on: ubuntu-latest
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 5e47994..ce2e28e 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -1,4 +1,4 @@
-on: [push]
+on: [pull_request]
 name: Coverage
 jobs:
   coverage:
diff --git a/.github/workflows/tests-linux.yml b/.github/workflows/tests-linux.yml
index f28075f..1f9c446 100644
--- a/.github/workflows/tests-linux.yml
+++ b/.github/workflows/tests-linux.yml
@@ -4,13 +4,11 @@ jobs:
   tests-linux:
     strategy:
       matrix:
-        go-version: [1.22.x, 1.23.x]
+        go-version: [1.24.x, 1.25.x, tip]
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-go@v5
-        with:
-          go-version-file: go.mod
+      - uses: actions/checkout@v5
+      - uses: actions/setup-go@v6
       - name: go mod tidy check
         run: go mod tidy -diff
       - name: Tests
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 654b94f..9d649df 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,7 +26,7 @@ The user interface aims to be simple, light and minimal.
 To bootstrap the UI for development:
  - cd to `internal/static`
  - run `npm install`
- - run `npm dev` and leave it running
+ - run `npm run dev` and leave it running
  - in another terminal, cd to an example, for example `_example/default`
  - run `go mod edit -replace=github.com/arl/statsviz=../../` to build the
    example with your local version of the Go code. If you haven't touched to the
diff --git a/_example/dev/go.sum b/_example/dev/go.sum
new file mode 100644
index 0000000..e4fa008
--- /dev/null
+++ b/_example/dev/go.sum
@@ -0,0 +1,8 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
+golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
diff --git a/_example/work.go b/_example/work.go
index cb55606..37cec34 100644
--- a/_example/work.go
+++ b/_example/work.go
@@ -17,10 +17,12 @@ func Work() {
 	for {
 		select {
 		case <-clearTick.C:
-			m = make(map[int64]any)
 			if rand.Intn(100) < 5 {
 				runtime.GC()
 			}
+			if rand.Intn(100) < 2 {
+				m = make(map[int64]any)
+			}
 		case ts := <-tick.C:
 			m[ts.UnixNano()] = newStruct()
 		}
diff --git a/clients.go b/clients.go
new file mode 100644
index 0000000..f455523
--- /dev/null
+++ b/clients.go
@@ -0,0 +1,99 @@
+package statsviz
+
+import (
+	"context"
+	"sync"
+
+	"github.com/gorilla/websocket"
+
+	"github.com/arl/statsviz/internal/plot"
+)
+
+type clients struct {
+	cfg *plot.Config
+	ctx context.Context
+
+	mu sync.RWMutex
+	m  map[*websocket.Conn]chan []byte
+}
+
+func newClients(ctx context.Context, cfg *plot.Config) *clients {
+	return &clients{
+		m:   make(map[*websocket.Conn]chan []byte),
+		cfg: cfg,
+		ctx: ctx,
+	}
+}
+
+type wsmsg struct {
+	Event string `json:"event"`
+	Data  any    `json:"data"`
+}
+
+func (c *clients) add(conn *websocket.Conn) {
+	dbglog("adding client")
+
+	// Send config first.
+	err := conn.WriteJSON(wsmsg{Event: "config", Data: c.cfg})
+	if err != nil {
+		dbglog("failed to send config: %v", err)
+		return
+	}
+
+	ch := make(chan []byte)
+
+	go func() {
+		defer func() {
+			c.mu.Lock()
+			delete(c.m, conn)
+			c.mu.Unlock()
+
+			dbglog("removed client")
+		}()
+
+		for {
+			select {
+			case <-c.ctx.Done():
+				return
+			case msg := <-ch:
+				if err := sendbuf(conn, msg); err != nil {
+					dbglog("failed to send data: %v", err)
+					return
+				}
+			}
+		}
+	}()
+
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.m[conn] = ch
+}
+
+func sendbuf(conn *websocket.Conn, buf []byte) error {
+	w, err := conn.NextWriter(websocket.TextMessage)
+	if err != nil {
+		return err
+	}
+	_, err1 := w.Write(buf)
+	err2 := w.Close()
+	if err1 != nil {
+		return err1
+	}
+	return err2
+}
+
+func (c *clients) broadcast(buf []byte) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	for _, ch := range c.m {
+		select {
+		case ch <- buf:
+		default:
+			// if a client is not keeping up, we
+			// drop the message for that client.
+			dbglog("dropping message to client")
+		}
+	}
+}
diff --git a/internal/plot/color.go b/internal/plot/color.go
index 6366fc2..926e846 100644
--- a/internal/plot/color.go
+++ b/internal/plot/color.go
@@ -20,45 +20,46 @@ func (c WeightedColor) MarshalJSON() ([]byte, error) {
 	return []byte(str), nil
 }
 
-// https://mdigi.tools/color-shades/
+// NOTE: shades obtained from https://mdigi.tools/color-shades/
+
 var BlueShades = []WeightedColor{
-	{Value: 0.0, Color: color.RGBA{0xea, 0xf8, 0xfd, 1}},
-	{Value: 0.1, Color: color.RGBA{0xbf, 0xeb, 0xfa, 1}},
-	{Value: 0.2, Color: color.RGBA{0x94, 0xdd, 0xf6, 1}},
-	{Value: 0.3, Color: color.RGBA{0x69, 0xd0, 0xf2, 1}},
-	{Value: 0.4, Color: color.RGBA{0x3f, 0xc2, 0xef, 1}},
-	{Value: 0.5, Color: color.RGBA{0x14, 0xb5, 0xeb, 1}},
-	{Value: 0.6, Color: color.RGBA{0x10, 0x94, 0xc0, 1}},
-	{Value: 0.7, Color: color.RGBA{0x0d, 0x73, 0x96, 1}},
-	{Value: 0.8, Color: color.RGBA{0x09, 0x52, 0x6b, 1}},
-	{Value: 0.9, Color: color.RGBA{0x05, 0x31, 0x40, 1}},
-	{Value: 1.0, Color: color.RGBA{0x02, 0x10, 0x15, 1}},
+	{Value: 0.0, Color: color.RGBA{0xea, 0xf8, 0xfd, 1}}, // rgb(234, 248, 253)
+	{Value: 0.1, Color: color.RGBA{0xbf, 0xeb, 0xfa, 1}}, // rgb(191, 235, 250)
+	{Value: 0.2, Color: color.RGBA{0x94, 0xdd, 0xf6, 1}}, // rgb(148, 221, 246)
+	{Value: 0.3, Color: color.RGBA{0x69, 0xd0, 0xf2, 1}}, // rgb(105, 208, 242)
+	{Value: 0.4, Color: color.RGBA{0x3f, 0xc2, 0xef, 1}}, // rgb(63, 194, 239)
+	{Value: 0.5, Color: color.RGBA{0x14, 0xb5, 0xeb, 1}}, // rgb(20, 181, 235)
+	{Value: 0.6, Color: color.RGBA{0x10, 0x94, 0xc0, 1}}, // rgb(16, 148, 192)
+	{Value: 0.7, Color: color.RGBA{0x0d, 0x73, 0x96, 1}}, // rgb(13, 115, 150)
+	{Value: 0.8, Color: color.RGBA{0x09, 0x52, 0x6b, 1}}, // rgb(9, 82, 107)
+	{Value: 0.9, Color: color.RGBA{0x05, 0x31, 0x40, 1}}, // rgb(5, 49, 64)
+	{Value: 1.0, Color: color.RGBA{0x02, 0x10, 0x15, 1}}, // rgb(2, 16, 21)
 }
 
 var PinkShades = []WeightedColor{
-	{Value: 0.0, Color: color.RGBA{0xfe, 0xe7, 0xf3, 1}},
-	{Value: 0.1, Color: color.RGBA{0xfc, 0xb6, 0xdc, 1}},
-	{Value: 0.2, Color: color.RGBA{0xf9, 0x85, 0xc5, 1}},
-	{Value: 0.3, Color: color.RGBA{0xf7, 0x55, 0xae, 1}},
-	{Value: 0.4, Color: color.RGBA{0xf5, 0x24, 0x96, 1}},
-	{Value: 0.5, Color: color.RGBA{0xdb, 0x0a, 0x7d, 1}},
-	{Value: 0.6, Color: color.RGBA{0xaa, 0x08, 0x61, 1}},
-	{Value: 0.7, Color: color.RGBA{0x7a, 0x06, 0x45, 1}},
-	{Value: 0.8, Color: color.RGBA{0x49, 0x03, 0x2a, 1}},
-	{Value: 0.9, Color: color.RGBA{0x18, 0x01, 0x0e, 1}},
-	{Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 1}},
+	{Value: 0.0, Color: color.RGBA{0xfe, 0xe7, 0xf3, 1}}, // rgb(254, 231, 243)
+	{Value: 0.1, Color: color.RGBA{0xfc, 0xb6, 0xdc, 1}}, // rgb(252, 182, 220)
+	{Value: 0.2, Color: color.RGBA{0xf9, 0x85, 0xc5, 1}}, // rgb(249, 133, 197)
+	{Value: 0.3, Color: color.RGBA{0xf7, 0x55, 0xae, 1}}, // rgb(247, 85, 174)
+	{Value: 0.4, Color: color.RGBA{0xf5, 0x24, 0x96, 1}}, // rgb(245, 36, 150)
+	{Value: 0.5, Color: color.RGBA{0xdb, 0x0a, 0x7d, 1}}, // rgb(219, 10, 125)
+	{Value: 0.6, Color: color.RGBA{0xaa, 0x08, 0x61, 1}}, // rgb(170, 8, 97)
+	{Value: 0.7, Color: color.RGBA{0x7a, 0x06, 0x45, 1}}, // rgb(122, 6, 69)
+	{Value: 0.8, Color: color.RGBA{0x49, 0x03, 0x2a, 1}}, // rgb(73, 3, 42)
+	{Value: 0.9, Color: color.RGBA{0x18, 0x01, 0x0e, 1}}, // rgb(24, 1, 14)
+	{Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 1}}, // rgb(0, 0, 0)
 }
 
 var GreenShades = []WeightedColor{
-	{Value: 0.0, Color: color.RGBA{0xed, 0xf7, 0xf2, 0}},
-	{Value: 0.1, Color: color.RGBA{0xc9, 0xe8, 0xd7, 0}},
-	{Value: 0.2, Color: color.RGBA{0xa5, 0xd9, 0xbc, 0}},
-	{Value: 0.3, Color: color.RGBA{0x81, 0xca, 0xa2, 0}},
-	{Value: 0.4, Color: color.RGBA{0x5e, 0xbb, 0x87, 0}},
-	{Value: 0.5, Color: color.RGBA{0x44, 0xa1, 0x6e, 0}},
-	{Value: 0.6, Color: color.RGBA{0x35, 0x7e, 0x55, 0}},
-	{Value: 0.7, Color: color.RGBA{0x26, 0x5a, 0x3d, 0}},
-	{Value: 0.8, Color: color.RGBA{0x17, 0x36, 0x25, 0}},
-	{Value: 0.9, Color: color.RGBA{0x08, 0x12, 0x0c, 0}},
-	{Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 0}},
+	{Value: 0.0, Color: color.RGBA{0xed, 0xf7, 0xf2, 0}}, // rgb(237, 247, 242)
+	{Value: 0.1, Color: color.RGBA{0xc9, 0xe8, 0xd7, 0}}, // rgb(201, 232, 215)
+	{Value: 0.2, Color: color.RGBA{0xa5, 0xd9, 0xbc, 0}}, // rgb(165, 217, 188)
+	{Value: 0.3, Color: color.RGBA{0x81, 0xca, 0xa2, 0}}, // rgb(129, 202, 162)
+	{Value: 0.4, Color: color.RGBA{0x5e, 0xbb, 0x87, 0}}, // rgb(94, 187, 135)
+	{Value: 0.5, Color: color.RGBA{0x44, 0xa1, 0x6e, 0}}, // rgb(68, 161, 110)
+	{Value: 0.6, Color: color.RGBA{0x35, 0x7e, 0x55, 0}}, // rgb(53, 126, 85)
+	{Value: 0.7, Color: color.RGBA{0x26, 0x5a, 0x3d, 0}}, // rgb(38, 90, 61)
+	{Value: 0.8, Color: color.RGBA{0x17, 0x36, 0x25, 0}}, // rgb(23, 54, 37)
+	{Value: 0.9, Color: color.RGBA{0x08, 0x12, 0x0c, 0}}, // rgb(8, 18, 12)
+	{Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 0}}, // rgb(0, 0, 0)
 }
diff --git a/internal/plot/helpers.go b/internal/plot/helpers.go
new file mode 100644
index 0000000..a1499b6
--- /dev/null
+++ b/internal/plot/helpers.go
@@ -0,0 +1,52 @@
+package plot
+
+import (
+	"runtime/debug"
+	"time"
+)
+
+// delta returns a function that computes the delta between successive calls.
+func delta[T uint64 | float64]() func(T) T {
+	first := true
+	var last T
+	return func(cur T) T {
+		delta := cur - last
+		if first {
+			delta = 0
+			first = false
+		}
+		last = cur
+		return delta
+	}
+}
+
+// rate returns a function that computes the rate of change per second.
+func rate[T uint64 | float64]() func(time.Time, T) float64 {
+	var last T
+	var lastTime time.Time
+
+	return func(now time.Time, cur T) float64 {
+		if lastTime.IsZero() {
+			last = cur
+			lastTime = now
+			return 0
+		}
+
+		t := now.Sub(lastTime).Seconds()
+		rate := float64(cur-last) / t
+
+		last = cur
+		lastTime = now
+
+		return rate
+	}
+}
+
+func goversion() string {
+	bnfo, ok := debug.ReadBuildInfo()
+	if ok {
+		return bnfo.GoVersion
+	}
+
+	return "<unknown version>"
+}
diff --git a/internal/plot/hist.go b/internal/plot/hist.go
index 3ab9884..240f327 100644
--- a/internal/plot/hist.go
+++ b/internal/plot/hist.go
@@ -85,3 +85,11 @@ func downsampleCounts(h *metrics.Float64Histogram, factor int, slice []uint64) [
 	// Whatever sum remains, it goes to the last bucket.
 	return append(slice, sum)
 }
+
+func floatseq(n int) []float64 {
+	seq := make([]float64, n)
+	for i := range n {
+		seq[i] = float64(i)
+	}
+	return seq
+}
diff --git a/internal/plot/indices_gen.go b/internal/plot/indices_gen.go
new file mode 100644
index 0000000..8bd0022
--- /dev/null
+++ b/internal/plot/indices_gen.go
@@ -0,0 +1,66 @@
+//go:build ignore
+
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"go/format"
+	"runtime/metrics"
+	"slices"
+	"strings"
+)
+
+func varname(metric string) string {
+	name, unit, ok := strings.Cut(metric, ":")
+	if !ok {
+		panic("didn't find ':' on " + metric)
+	}
+
+	name = "idx" + name + "_" + unit
+	name = strings.ReplaceAll(name, "/", "_")
+	name = strings.ReplaceAll(name, "-", "_")
+
+	return name
+}
+
+type idxname struct {
+	idx  string
+	name string
+}
+
+// TODO: we could also get the number of buckets for histograms, and preprocess other stuff.
+
+func main() {
+	var all []idxname
+	for _, m := range metrics.All() {
+		if !strings.HasPrefix(m.Name, "/godebug/") {
+			all = append(all, idxname{varname(m.Name), m.Name})
+		}
+	}
+	slices.SortFunc(all, func(a, b idxname) int {
+		return strings.Compare(a.idx, b.idx)
+	})
+
+	var out bytes.Buffer
+
+	fmt.Fprintln(&out, `package plot
+// Code generated by internal/plot/indices_gen.go; DO NOT EDIT.
+
+//lint:file-ignore ST1003 Ignore underscore in generated index names
+//lint:file-ignore U1000 Ignore unused indices. they're generated
+`)
+	fmt.Fprintln(&out, `var (`)
+	for _, m := range all {
+		fmt.Fprintf(&out, "%s = mustidx(%q)\n", m.idx, m.name)
+	}
+	fmt.Fprintln(&out, `)`)
+
+	formatted, err := format.Source(out.Bytes())
+	if err != nil {
+		fmt.Println("format error:", err)
+		fmt.Printf("%s", out.String())
+		panic(err)
+	}
+	fmt.Println(string(formatted))
+}
diff --git a/internal/plot/indices_go1.26.go b/internal/plot/indices_go1.26.go
new file mode 100644
index 0000000..73b06b5
--- /dev/null
+++ b/internal/plot/indices_go1.26.go
@@ -0,0 +1,21 @@
+//go:build go1.26
+
+package plot
+
+// Code generated by internal/plot/generate_indices.go; DO NOT EDIT.
+
+//lint:file-ignore ST1003 Ignore underscore in generated index names
+//lint:file-ignore U1000 Ignore unused indices. they're generated
+
+var (
+	idx_gc_cleanups_executed_cleanups         = mustidx("/gc/cleanups/executed:cleanups")
+	idx_gc_cleanups_queued_cleanups           = mustidx("/gc/cleanups/queued:cleanups")
+	idx_gc_finalizers_executed_finalizers     = mustidx("/gc/finalizers/executed:finalizers")
+	idx_gc_finalizers_queued_finalizers       = mustidx("/gc/finalizers/queued:finalizers")
+	idx_sched_goroutines_created_goroutines   = mustidx("/sched/goroutines-created:goroutines")
+	idx_sched_goroutines_not_in_go_goroutines = mustidx("/sched/goroutines/not-in-go:goroutines")
+	idx_sched_goroutines_runnable_goroutines  = mustidx("/sched/goroutines/runnable:goroutines")
+	idx_sched_goroutines_running_goroutines   = mustidx("/sched/goroutines/running:goroutines")
+	idx_sched_goroutines_waiting_goroutines   = mustidx("/sched/goroutines/waiting:goroutines")
+	idx_sched_threads_total_threads           = mustidx("/sched/threads/total:threads")
+)
diff --git a/internal/plot/indices_pre_go1.26.go b/internal/plot/indices_pre_go1.26.go
new file mode 100644
index 0000000..cd5c2e1
--- /dev/null
+++ b/internal/plot/indices_pre_go1.26.go
@@ -0,0 +1,59 @@
+package plot
+
+// Code generated by internal/plot/indices_gen.go; DO NOT EDIT.
+
+//lint:file-ignore ST1003 Ignore underscore in generated index names
+//lint:file-ignore U1000 Ignore unused indices. they're generated
+
+var (
+	idx_cgo_go_to_c_calls_calls                     = mustidx("/cgo/go-to-c-calls:calls")
+	idx_cpu_classes_gc_mark_assist_cpu_seconds      = mustidx("/cpu/classes/gc/mark/assist:cpu-seconds")
+	idx_cpu_classes_gc_mark_dedicated_cpu_seconds   = mustidx("/cpu/classes/gc/mark/dedicated:cpu-seconds")
+	idx_cpu_classes_gc_mark_idle_cpu_seconds        = mustidx("/cpu/classes/gc/mark/idle:cpu-seconds")
+	idx_cpu_classes_gc_pause_cpu_seconds            = mustidx("/cpu/classes/gc/pause:cpu-seconds")
+	idx_cpu_classes_gc_total_cpu_seconds            = mustidx("/cpu/classes/gc/total:cpu-seconds")
+	idx_cpu_classes_idle_cpu_seconds                = mustidx("/cpu/classes/idle:cpu-seconds")
+	idx_cpu_classes_scavenge_assist_cpu_seconds     = mustidx("/cpu/classes/scavenge/assist:cpu-seconds")
+	idx_cpu_classes_scavenge_background_cpu_seconds = mustidx("/cpu/classes/scavenge/background:cpu-seconds")
+	idx_cpu_classes_scavenge_total_cpu_seconds      = mustidx("/cpu/classes/scavenge/total:cpu-seconds")
+	idx_cpu_classes_total_cpu_seconds               = mustidx("/cpu/classes/total:cpu-seconds")
+	idx_cpu_classes_user_cpu_seconds                = mustidx("/cpu/classes/user:cpu-seconds")
+	idx_gc_cycles_automatic_gc_cycles               = mustidx("/gc/cycles/automatic:gc-cycles")
+	idx_gc_cycles_forced_gc_cycles                  = mustidx("/gc/cycles/forced:gc-cycles")
+	idx_gc_cycles_total_gc_cycles                   = mustidx("/gc/cycles/total:gc-cycles")
+	idx_gc_gomemlimit_bytes                         = mustidx("/gc/gomemlimit:bytes")
+	idx_gc_heap_allocs_by_size_bytes                = mustidx("/gc/heap/allocs-by-size:bytes")
+	idx_gc_heap_allocs_bytes                        = mustidx("/gc/heap/allocs:bytes")
+	idx_gc_heap_allocs_objects                      = mustidx("/gc/heap/allocs:objects")
+	idx_gc_heap_frees_by_size_bytes                 = mustidx("/gc/heap/frees-by-size:bytes")
+	idx_gc_heap_frees_bytes                         = mustidx("/gc/heap/frees:bytes")
+	idx_gc_heap_frees_objects                       = mustidx("/gc/heap/frees:objects")
+	idx_gc_heap_goal_bytes                          = mustidx("/gc/heap/goal:bytes")
+	idx_gc_heap_live_bytes                          = mustidx("/gc/heap/live:bytes")
+	idx_gc_heap_objects_objects                     = mustidx("/gc/heap/objects:objects")
+	idx_gc_scan_globals_bytes                       = mustidx("/gc/scan/globals:bytes")
+	idx_gc_scan_heap_bytes                          = mustidx("/gc/scan/heap:bytes")
+	idx_gc_scan_stack_bytes                         = mustidx("/gc/scan/stack:bytes")
+	idx_gc_stack_starting_size_bytes                = mustidx("/gc/stack/starting-size:bytes")
+	idx_memory_classes_heap_free_bytes              = mustidx("/memory/classes/heap/free:bytes")
+	idx_memory_classes_heap_objects_bytes           = mustidx("/memory/classes/heap/objects:bytes")
+	idx_memory_classes_heap_released_bytes          = mustidx("/memory/classes/heap/released:bytes")
+	idx_memory_classes_heap_stacks_bytes            = mustidx("/memory/classes/heap/stacks:bytes")
+	idx_memory_classes_heap_unused_bytes            = mustidx("/memory/classes/heap/unused:bytes")
+	idx_memory_classes_metadata_mcache_free_bytes   = mustidx("/memory/classes/metadata/mcache/free:bytes")
+	idx_memory_classes_metadata_mcache_inuse_bytes  = mustidx("/memory/classes/metadata/mcache/inuse:bytes")
+	idx_memory_classes_metadata_mspan_free_bytes    = mustidx("/memory/classes/metadata/mspan/free:bytes")
+	idx_memory_classes_metadata_mspan_inuse_bytes   = mustidx("/memory/classes/metadata/mspan/inuse:bytes")
+	idx_memory_classes_os_stacks_bytes              = mustidx("/memory/classes/os-stacks:bytes")
+	idx_memory_classes_other_bytes                  = mustidx("/memory/classes/other:bytes")
+	idx_memory_classes_profiling_buckets_bytes      = mustidx("/memory/classes/profiling/buckets:bytes")
+	idx_memory_classes_total_bytes                  = mustidx("/memory/classes/total:bytes")
+	idx_sched_gomaxprocs_threads                    = mustidx("/sched/gomaxprocs:threads")
+	idx_sched_goroutines_goroutines                 = mustidx("/sched/goroutines:goroutines")
+	idx_sched_latencies_seconds                     = mustidx("/sched/latencies:seconds")
+	idx_sched_pauses_stopping_gc_seconds            = mustidx("/sched/pauses/stopping/gc:seconds")
+	idx_sched_pauses_stopping_other_seconds         = mustidx("/sched/pauses/stopping/other:seconds")
+	idx_sched_pauses_total_gc_seconds               = mustidx("/sched/pauses/total/gc:seconds")
+	idx_sched_pauses_total_other_seconds            = mustidx("/sched/pauses/total/other:seconds")
+	idx_sync_mutex_wait_total_seconds               = mustidx("/sync/mutex/wait/total:seconds")
+)
diff --git a/internal/plot/layouts.go b/internal/plot/layouts.go
deleted file mode 100644
index 213c352..0000000
--- a/internal/plot/layouts.go
+++ /dev/null
@@ -1,716 +0,0 @@
-package plot
-
-import (
-	"runtime/metrics"
-)
-
-func floatseq(n int) []float64 {
-	seq := make([]float64, n)
-	for i := range n {
-		seq[i] = float64(i)
-	}
-	return seq
-}
-
-var garbageCollectionLayout = Scatter{
-	Name:   "garbage collection",
-	Title:  "GC Memory Summary",
-	Type:   "scatter",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title:      "bytes",
-			TickSuffix: "B",
-		},
-	},
-	Subplots: []Subplot{
-		{Name: "memory limit", Unitfmt: "%{y:.4s}B"},
-		{Name: "in-use memory", Unitfmt: "%{y:.4s}B"},
-		{Name: "heap live", Unitfmt: "%{y:.4s}B"},
-		{Name: "heap goal", Unitfmt: "%{y:.4s}B"},
-	},
-	InfoText: `
-<i>Memory limit</i> is <b>/gc/gomemlimit:bytes</b>, the Go runtime memory limit configured by the user (via GOMEMLIMIT or debug.SetMemoryLimt), otherwise 0. 
-<i>In-use memory</i> is the total mapped memory minus released heap memory (<b>/memory/classes/total - /memory/classes/heap/released</b>).
-<i>Heap live</i> is <b>/gc/heap/live:bytes</b>, heap memory occupied by live objects.  
-<i>Heap goal</i> is <b>/gc/heap/goal:bytes</b>, the heap size target at the end of each GC cycle.`,
-}
-
-var heapDetailslLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "Heap (details)",
-	Type:   "scatter",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title:      "bytes",
-			TickSuffix: "B",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "heap sys",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "heap objects",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "heap stacks",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "heap goal",
-			Unitfmt: "%{y:.4s}B",
-		},
-	},
-	InfoText: `
-<i>Heap</i> sys is <b>/memory/classes/heap/{objects + unused + released + free}</b>. It's an estimate of all the heap memory obtained from the OS.
-<i>Heap objects</i> is <b>/memory/classes/heap/objects</b>, the memory occupied by live objects and dead objects that have not yet been marked free by the GC.
-<i>Heap stacks</i> is <b>/memory/classes/heap/stacks</b>, the memory used for stack space.
-<i>Heap goal</i> is <b>gc/heap/goal</b>, the heap size target for the end of the GC cycle.`,
-}
-
-var liveObjectsLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "Live Objects in Heap",
-	Type:   "bar",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "objects",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "live objects",
-			Unitfmt: "%{y:.4s}",
-			Color:   RGBString(255, 195, 128),
-		},
-	},
-	InfoText: `<i>Live objects</i> is <b>/gc/heap/objects</b>. It's the number of objects, live or unswept, occupying heap memory.`,
-}
-
-var liveBytesLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "Live Bytes in Heap",
-	Type:   "bar",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "bytes",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "live bytes",
-			Unitfmt: "%{y:.4s}B",
-			Color:   RGBString(135, 182, 218),
-		},
-	},
-	InfoText: `<i>Live bytes</i> is <b>/gc/heap/allocs - /gc/heap/frees</b>. It's the number of bytes currently allocated (and not yet GC'ec) to the heap by the application.`,
-}
-
-var mspanMCacheLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "MSpan/MCache",
-	Type:   "scatter",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title:      "bytes",
-			TickSuffix: "B",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "mspan in-use",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "mspan free",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "mcache in-use",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "mcache free",
-			Unitfmt: "%{y:.4s}B",
-		},
-	},
-	InfoText: `
-<i>Mspan in-use</i> is <b>/memory/classes/metadata/mspan/inuse</b>, the memory that is occupied by runtime mspan structures that are currently being used.
-<i>Mspan free</i> is <b>/memory/classes/metadata/mspan/free</b>, the memory that is reserved for runtime mspan structures, but not in-use.
-<i>Mcache in-use</i> is <b>/memory/classes/metadata/mcache/inuse</b>, the memory that is occupied by runtime mcache structures that are currently being used.
-<i>Mcache free</i> is <b>/memory/classes/metadata/mcache/free</b>, the memory that is reserved for runtime mcache structures, but not in-use.
-`,
-}
-
-var goroutinesLayout = Scatter{
-	Name:  "TODO(set later)",
-	Title: "Goroutines",
-	Type:  "scatter",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "goroutines",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "goroutines",
-			Unitfmt: "%{y}",
-		},
-	},
-	InfoText: "<i>Goroutines</i> is <b>/sched/goroutines</b>, the count of live goroutines.",
-}
-
-func sizeClassesLayout(samples []metrics.Sample) Heatmap {
-	idxallocs := metricIdx["/gc/heap/allocs-by-size:bytes"]
-	idxfrees := metricIdx["/gc/heap/frees-by-size:bytes"]
-
-	// Perform a sanity check on the number of buckets on the 'allocs' and
-	// 'frees' size classes histograms. Statsviz plots a single histogram based
-	// on those 2 so we want them to have the same number of buckets, which
-	// should be true.
-	allocsBySize := samples[idxallocs].Value.Float64Histogram()
-	freesBySize := samples[idxfrees].Value.Float64Histogram()
-	if len(allocsBySize.Buckets) != len(freesBySize.Buckets) {
-		panic("different number of buckets in allocs and frees size classes histograms")
-	}
-
-	// No downsampling for the size classes histogram (factor=1) but we still
-	// need to adapt boundaries for plotly heatmaps.
-	buckets := downsampleBuckets(allocsBySize, 1)
-
-	return Heatmap{
-		Name:       "TODO(set later)",
-		Title:      "Size Classes",
-		Type:       "heatmap",
-		UpdateFreq: 5,
-		Colorscale: BlueShades,
-		Buckets:    floatseq(len(buckets)),
-		CustomData: buckets,
-		Hover: HeapmapHover{
-			YName: "size class",
-			YUnit: "bytes",
-			ZName: "objects",
-		},
-		InfoText: `This heatmap shows the distribution of size classes, using <b>/gc/heap/allocs-by-size</b> and <b>/gc/heap/frees-by-size</b>.`,
-		Layout: HeatmapLayout{
-			YAxis: HeatmapYaxis{
-				Title:    "size class",
-				TickMode: "array",
-				TickVals: []float64{1, 9, 17, 25, 31, 37, 43, 50, 58, 66},
-				TickText: []float64{1 << 4, 1 << 7, 1 << 8, 1 << 9, 1 << 10, 1 << 11, 1 << 12, 1 << 13, 1 << 14, 1 << 15},
-			},
-		},
-	}
-}
-
-func runnableTimeLayout(samples []metrics.Sample) Heatmap {
-	idxschedlat := metricIdx["/sched/latencies:seconds"]
-
-	schedlat := samples[idxschedlat].Value.Float64Histogram()
-	histfactor := downsampleFactor(len(schedlat.Buckets), maxBuckets)
-	buckets := downsampleBuckets(schedlat, histfactor)
-
-	return Heatmap{
-		Name:       "TODO(set later)",
-		Title:      "Time Goroutines Spend in 'Runnable' state",
-		Type:       "heatmap",
-		UpdateFreq: 5,
-		Colorscale: GreenShades,
-		Buckets:    floatseq(len(buckets)),
-		CustomData: buckets,
-		Hover: HeapmapHover{
-			YName: "duration",
-			YUnit: "duration",
-			ZName: "goroutines",
-		},
-		Layout: HeatmapLayout{
-			YAxis: HeatmapYaxis{
-				Title:    "duration",
-				TickMode: "array",
-				TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
-				TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
-			},
-		},
-		InfoText: `This heatmap shows the distribution of the time goroutines have spent in the scheduler in a runnable state before actually running, uses <b>/sched/latencies:seconds</b>.`,
-	}
-}
-
-var schedEventsLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "Goroutine Scheduling Events",
-	Type:   "scatter",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "events",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "events per unit of time",
-			Unitfmt: "%{y}",
-		},
-		{
-			Name:    "events per unit of time, per P",
-			Unitfmt: "%{y}",
-		},
-	},
-	InfoText: `<i>Events per second</i> is the sum of all buckets in <b>/sched/latencies:seconds</b>, that is, it tracks the total number of goroutine scheduling events. That number is multiplied by the constant 8.
-<i>Events per second per P (processor)</i> is <i>Events per second</i> divided by current <b>GOMAXPROCS</b>, from <b>/sched/gomaxprocs:threads</b>.
-<b>NOTE</b>: the multiplying factor comes from internal Go runtime source code and might change from version to version.`,
-}
-
-var cgoLayout = Scatter{
-	Name:  "TODO(set later)",
-	Title: "CGO Calls",
-	Type:  "bar",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "calls",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "calls from go to c",
-			Unitfmt: "%{y}",
-			Color:   "red",
-		},
-	},
-	InfoText: "Shows the count of calls made from Go to C by the current process, per unit of time. Uses <b>/cgo/go-to-c-calls:calls</b>",
-}
-
-var gcStackSizeLayout = Scatter{
-	Name:  "TODO(set later)",
-	Title: "Goroutine stack starting size",
-	Type:  "scatter",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "bytes",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "new goroutines stack size",
-			Unitfmt: "%{y:.4s}B",
-		},
-	},
-	InfoText: "Shows the stack size of new goroutines, uses <b>/gc/stack/starting-size:bytes</b>",
-}
-
-var gcCyclesLayout = Scatter{
-	Name:  "TODO(set later)",
-	Title: "Completed GC Cycles",
-	Type:  "bar",
-	Layout: ScatterLayout{
-		BarMode: "stack",
-		Yaxis: ScatterYAxis{
-			Title: "cycles",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "automatic",
-			Unitfmt: "%{y}",
-			Type:    "bar",
-		},
-		{
-			Name:    "forced",
-			Unitfmt: "%{y}",
-			Type:    "bar",
-		},
-	},
-	InfoText: `Number of completed GC cycles, either forced of generated by the Go runtime.`,
-}
-
-var memoryClassesLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "Memory classes",
-	Type:   "scatter",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title:      "bytes",
-			TickSuffix: "B",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "os stacks",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "other",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "profiling buckets",
-			Unitfmt: "%{y:.4s}B",
-		},
-		{
-			Name:    "total",
-			Unitfmt: "%{y:.4s}B",
-		},
-	},
-
-	InfoText: `
-<i>OS stacks</i> is <b>/memory/classes/os-stacks</b>, stack memory allocated by the underlying operating system.
-<i>Other</i> is <b>/memory/classes/other</b>, memory used by execution trace buffers, structures for debugging the runtime, finalizer and profiler specials, and more.
-<i>Profiling buckets</i> is <b>/memory/classes/profiling/buckets</b>, memory that is used by the stack trace hash map used for profiling.
-<i>Total</i> is <b>/memory/classes/total</b>, all memory mapped by the Go runtime into the current process as read-write.`,
-}
-
-var cpuGCLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "CPU (Garbage Collector)",
-	Type:   "scatter",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title:      "cpu-seconds per seconds",
-			TickSuffix: "s",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "mark assist",
-			Unitfmt: "%{y:.4s}s",
-		},
-		{
-			Name:    "mark dedicated",
-			Unitfmt: "%{y:.4s}s",
-		},
-		{
-			Name:    "mark idle",
-			Unitfmt: "%{y:.4s}s",
-		},
-		{
-			Name:    "pause",
-			Unitfmt: "%{y:.4s}s",
-		},
-	},
-
-	InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable.
-All this metrics are overestimates, and not directly comparable to system CPU time measurements. Compare only with other /cpu/classes metrics.
-
-<i>mark assist</i> is <b>/cpu/classes/gc/mark/assist</b>, estimated total CPU time goroutines spent performing GC tasks to assist the GC and prevent it from falling behind the application.
-<i>mark dedicated</i> is <b>/cpu/classes/gc/mark/dedicated</b>, Estimated total CPU time spent performing GC tasks on processors (as defined by GOMAXPROCS) dedicated to those tasks.
-<i>mark idle</i> is <b>/cpu/classes/gc/mark/idle</b>, estimated total CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for.
-<i>pause</i> is <b>/cpu/classes/gc/pause</b>, estimated total CPU time spent with the application paused by the GC.
-
-All metrics are rates in CPU-seconds per second.`,
-}
-
-var cpuScavengerLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "CPU (Scavenger)",
-	Type:   "bar",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		BarMode: "stack",
-		Yaxis: ScatterYAxis{
-			Title:      "cpu-seconds / second",
-			TickSuffix: "s",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "assist",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-		{
-			Name:    "background",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-	},
-	InfoText: `Breakdown of how the GC scavenger returns memory to the OS (eagerly vs background).
-<i>assist is</i> the rate of <b>/cpu/classes/scavenge/assist</b>, the CPU time spent returning unused memory eagerly in response to memory pressure.
-<i>background is</i> the rate of <b>/cpu/classes/scavenge/background</b>, the CPU time spent performing background tasks to return unused memory to the OS.
-
-Both metrics are rates in CPU-seconds per second.`,
-}
-
-var cpuOverallLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "CPU (Overall)",
-	Type:   "bar",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		BarMode: "stack",
-		Yaxis: ScatterYAxis{
-			Title:      "cpu-seconds / second",
-			TickSuffix: "s",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "user",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-		{
-			Name:    "scavenge",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-		{
-			Name:    "idle",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-		{
-			Name:    "gc total",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-		{
-			Name:    "total",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "scatter",
-		},
-	},
-	InfoText: `Shows the fraction of CPU spent in your code vs. runtime vs. wasted. Helps track overall utilization and potential headroom.
-<i>user is</i> the rate of <b>/cpu/classes/user:cpu-seconds</b>, the CPU time spent running user Go code.
-<i>scavenge is</i> the rate of <b>/cpu/classes/scavenge:cpu-seconds</b>, the CPU time spent performing tasks that return unused memory to the OS.
-<i>idle is</i> the rate of <b>/cpu/classes/idle:cpu-seconds</b>, the CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for.
-<i>gc total is</i> the rate of <b>/cpu/classes/gc/total:cpu-seconds</b>, the CPU time spent performing GC tasks (sum of all metrics in <b>/cpu/classes/gc</b>)
-<i>total is</i> the rate of <b>/cpu/classes/total:cpu-seconds</b>, the available CPU time for user Go code or the Go runtime, as defined by GOMAXPROCS. In other words, GOMAXPROCS integrated over the wall-clock duration this process has been executing for.
-
-All metrics are rates in CPU-seconds per second.`,
-}
-
-var mutexWaitLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "Mutex wait time",
-	Type:   "bar",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title:      "seconds / second",
-			TickSuffix: "s",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "mutex wait",
-			Unitfmt: "%{y:.4s}s",
-			Type:    "bar",
-		},
-	},
-
-	InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable.
-<i>mutex wait</i> is <b>/sync/mutex/wait/total</b>, approximate cumulative time goroutines have spent blocked on a sync.Mutex or sync.RWMutex.
-
-This metric is useful for identifying global changes in lock contention. Collect a mutex or block profile using the runtime/pprof package for more detailed contention data.`,
-}
-
-var gcScanLayout = Scatter{
-	Name:   "TODO(set later)",
-	Title:  "GC Scan",
-	Type:   "bar",
-	Events: "lastgc",
-	Layout: ScatterLayout{
-		BarMode: "stack",
-		Yaxis: ScatterYAxis{
-			TickSuffix: "B",
-			Title:      "bytes",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "scannable globals",
-			Unitfmt: "%{y:.4s}B",
-			Type:    "bar",
-		},
-		{
-			Name:    "scannable heap",
-			Unitfmt: "%{y:.4s}B",
-			Type:    "bar",
-		},
-		{
-			Name:    "scanned stack",
-			Unitfmt: "%{y:.4s}B",
-			Type:    "bar",
-		},
-	},
-	InfoText: `
-This plot shows the amount of memory that is scannable by the GC.
-<i>scannable globals</i> is <b>/gc/scan/globals</b>, the total amount of global variable space that is scannable.
-<i>scannable heap</i> is <b>/gc/scan/heap</b>, the total amount of heap space that is scannable.
-<i>scanned stack</i> is <b>/gc/scan/stack</b>, the number of bytes of stack that were scanned last GC cycle.
-`,
-}
-
-var allocFreeRatesLayout = Scatter{
-	Name:  "heap alloc/free rates",
-	Title: "Heap Allocation & Free Rates",
-	Type:  "scatter",
-	Layout: ScatterLayout{
-		Yaxis: ScatterYAxis{
-			Title: "objects / second",
-		},
-	},
-	Subplots: []Subplot{
-		{
-			Name:    "allocs/sec",
-			Unitfmt: "%{y:.4s}",
-			Color:   RGBString(66, 133, 244),
-		},
-		{
-			Name:    "frees/sec",
-			Unitfmt: "%{y:.4s}",
-			Color:   RGBString(219, 68, 55),
-		},
-	},
-	InfoText: `
-<i>Allocations per second</i> is derived by differencing the cumulative <b>/gc/heap/allocs:objects</b> metric.
-<i>Frees per second</i> is similarly derived from <b>/gc/heap/frees:objects</b>.`,
-}
-
-func gcTotalPausesLayout(samples []metrics.Sample) Heatmap {
-	idxgcpauses := metricIdx["/sched/pauses/total/gc:seconds"]
-
-	gcpauses := samples[idxgcpauses].Value.Float64Histogram()
-	histfactor := downsampleFactor(len(gcpauses.Buckets), maxBuckets)
-	buckets := downsampleBuckets(gcpauses, histfactor)
-
-	return Heatmap{
-		Name:       "TODO(set later)",
-		Title:      "Stop-the-world Pause Latencies (Total)",
-		Type:       "heatmap",
-		UpdateFreq: 5,
-		Colorscale: PinkShades,
-		Buckets:    floatseq(len(buckets)),
-		CustomData: buckets,
-		Hover: HeapmapHover{
-			YName: "pause duration",
-			YUnit: "duration",
-			ZName: "pauses",
-		},
-		Layout: HeatmapLayout{
-			YAxis: HeatmapYaxis{
-				Title:    "pause duration",
-				TickMode: "array",
-				TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
-				TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
-			},
-		},
-		InfoText: `This heatmap shows the distribution of individual <b>GC-related</b> stop-the-world <i>pause latencies</i>.
-This is the time from deciding to stop the world until the world is started again.
-Some of this time is spent getting all threads to stop (this is measured directly in <i>/sched/pauses/stopping/gc:seconds</i>), during which some threads may still be running.
-Uses <b>/sched/pauses/total/gc:seconds</b>.`,
-	}
-}
-
-func otherTotalPausesLayout(samples []metrics.Sample) Heatmap {
-	idxtotalother := metricIdx["/sched/pauses/total/other:seconds"]
-
-	totalother := samples[idxtotalother].Value.Float64Histogram()
-	histfactor := downsampleFactor(len(totalother.Buckets), maxBuckets)
-	buckets := downsampleBuckets(totalother, histfactor)
-
-	return Heatmap{
-		Name:       "TODO(set later)",
-		Title:      "Stop-the-world Pause Latencies (Other)",
-		Type:       "heatmap",
-		UpdateFreq: 5,
-		Colorscale: PinkShades,
-		Buckets:    floatseq(len(buckets)),
-		CustomData: buckets,
-		Hover: HeapmapHover{
-			YName: "pause duration",
-			YUnit: "duration",
-			ZName: "pauses",
-		},
-		Layout: HeatmapLayout{
-			YAxis: HeatmapYaxis{
-				Title:    "pause duration",
-				TickMode: "array",
-				TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
-				TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
-			},
-		},
-		InfoText: `This heatmap shows the distribution of individual <b>non-GC-related</b> stop-the-world <i>pause latencies</i>.
-This is the time from deciding to stop the world until the world is started again.
-Some of this time is spent getting all threads to stop (measured directly in <i>/sched/pauses/stopping/other:seconds</i>).
-Uses <b>/sched/pauses/total/other:seconds</b>.`,
-	}
-}
-
-func gcStoppingPausesLayout(samples []metrics.Sample) Heatmap {
-	idxstoppinggc := metricIdx["/sched/pauses/stopping/gc:seconds"]
-
-	stoppinggc := samples[idxstoppinggc].Value.Float64Histogram()
-	histfactor := downsampleFactor(len(stoppinggc.Buckets), maxBuckets)
-	buckets := downsampleBuckets(stoppinggc, histfactor)
-
-	return Heatmap{
-		Name:       "TODO(set later)",
-		Title:      "Stop-the-world Stopping Latencies (GC)",
-		Type:       "heatmap",
-		UpdateFreq: 5,
-		Colorscale: PinkShades,
-		Buckets:    floatseq(len(buckets)),
-		CustomData: buckets,
-		Hover: HeapmapHover{
-			YName: "stopping duration",
-			YUnit: "duration",
-			ZName: "pauses",
-		},
-		Layout: HeatmapLayout{
-			YAxis: HeatmapYaxis{
-				Title:    "stopping duration",
-				TickMode: "array",
-				TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
-				TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
-			},
-		},
-		InfoText: `This heatmap shows the distribution of individual <b>GC-related</b> stop-the-world <i>stopping latencies</i>.
-This is the time it takes from deciding to stop the world until all Ps are stopped.
-During this time, some threads may be executing.
-Uses <b>/sched/pauses/stopping/gc:seconds</b>.`,
-	}
-}
-
-func otherStoppingPausesLayout(samples []metrics.Sample) Heatmap {
-	idxstoppingother := metricIdx["/sched/pauses/stopping/other:seconds"]
-
-	stoppingother := samples[idxstoppingother].Value.Float64Histogram()
-	histfactor := downsampleFactor(len(stoppingother.Buckets), maxBuckets)
-	buckets := downsampleBuckets(stoppingother, histfactor)
-
-	return Heatmap{
-		Name:       "TODO(set later)",
-		Title:      "Stop-the-world Stopping Latencies (Other)",
-		Type:       "heatmap",
-		UpdateFreq: 5,
-		Colorscale: PinkShades,
-		Buckets:    floatseq(len(buckets)),
-		CustomData: buckets,
-		Hover: HeapmapHover{
-			YName: "stopping duration",
-			YUnit: "duration",
-			ZName: "pauses",
-		},
-		Layout: HeatmapLayout{
-			YAxis: HeatmapYaxis{
-				Title:    "stopping duration",
-				TickMode: "array",
-				TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
-				TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
-			},
-		},
-		InfoText: `This heatmap shows the distribution of individual <b>non-GC-related</b> stop-the-world <i>stopping latencies</i>.
-This is the time it takes from deciding to stop the world until all Ps are stopped.
-This is a subset of the total non-GC-related stop-the-world time. During this time, some threads may be executing.
-Uses <b>/sched/pauses/stopping/other:seconds</b>.`,
-	}
-}
diff --git a/internal/plot/list.go b/internal/plot/list.go
new file mode 100644
index 0000000..39c06cd
--- /dev/null
+++ b/internal/plot/list.go
@@ -0,0 +1,156 @@
+package plot
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"runtime/debug"
+	"runtime/metrics"
+	"slices"
+	"sync"
+	"time"
+)
+
+// IsReservedPlotName reports whether that name is reserved for Statsviz plots
+// and thus can't be used for a user plot.
+func IsReservedPlotName(name string) bool {
+	if name == "timestamp" || name == "lastgc" {
+		return true
+	}
+	registry := reg()
+	return slices.ContainsFunc(registry.descriptions, func(pd description) bool {
+		return nameFromLayout(pd.layout) == name
+	})
+}
+
+func nameFromLayout(layout any) string {
+	switch layout := layout.(type) {
+	case Scatter:
+		return layout.Name
+	case Heatmap:
+		return layout.Name
+	default:
+		panic(fmt.Sprintf("unknown plot layout type %T", layout))
+	}
+}
+
+// getvalues extracts, from a sample of runtime metrics, a slice with all
+// the metrics necessary for a single plot.
+type getvalues func(time.Time, []metrics.Sample) any
+
+// List holds all the plots that statsviz knows about. Some plots might be
+// disabled, if they rely on metrics that are unknown to the current Go version.
+type List struct {
+	rtPlots   []runtimePlot
+	userPlots []UserPlot
+
+	once sync.Once // ensure Config is built once
+	cfg  *Config
+
+	reg *registry
+}
+
+type runtimePlot struct {
+	name    string
+	getvals getvalues
+	layout  any // Scatter | Heatmap
+}
+
+func NewList(userPlots []UserPlot) (*List, error) {
+	if name := hasDuplicatePlotNames(userPlots); name != "" {
+		return nil, fmt.Errorf("duplicate plot name %s", name)
+	}
+
+	return &List{reg: reg(), userPlots: userPlots}, nil
+}
+
+func (pl *List) enabledPlots() []runtimePlot {
+	plots := make([]runtimePlot, 0, len(pl.reg.descriptions))
+
+	for _, plot := range pl.reg.descriptions {
+		plots = append(plots, runtimePlot{
+			name:    nameFromLayout(plot.layout),
+			getvals: plot.getvalues(),
+			layout:  plot.layout,
+		})
+	}
+
+	return plots
+}
+
+func (pl *List) Config() *Config {
+	pl.once.Do(func() {
+		pl.rtPlots = pl.enabledPlots()
+
+		layouts := make([]any, len(pl.rtPlots))
+		for i := range pl.rtPlots {
+			layouts[i] = pl.rtPlots[i].layout
+		}
+
+		pl.cfg = &Config{
+			Events: []string{"lastgc"},
+			Series: layouts,
+		}
+
+		// User plots go at the back.
+		for i := range pl.userPlots {
+			pl.cfg.Series = append(pl.cfg.Series, pl.userPlots[i].Layout())
+		}
+	})
+	return pl.cfg
+}
+
+// WriteTo writes into w a JSON object containing the data points for all plots
+// at the current instant. Return the number of written plots.
+func (pl *List) WriteTo(w io.Writer) (int64, error) {
+	samples := pl.reg.read()
+
+	// lastgc time series is used as source to represent garbage collection
+	// timestamps as vertical bars on certain plots.
+	gcStats := debug.GCStats{}
+	debug.ReadGCStats(&gcStats)
+
+	m := map[string]any{
+		// Javascript timestamps are in milliseconds.
+		"lastgc": []int64{gcStats.LastGC.UnixMilli()},
+	}
+	now := time.Now()
+	for _, p := range pl.rtPlots {
+		m[p.name] = p.getvals(now, samples)
+	}
+
+	for i := range pl.userPlots {
+		up := &pl.userPlots[i]
+		switch {
+		case up.Scatter != nil:
+			vals := make([]float64, len(up.Scatter.Funcs))
+			for i := range up.Scatter.Funcs {
+				vals[i] = up.Scatter.Funcs[i]()
+			}
+			m[up.Scatter.Plot.Name] = vals
+		case up.Heatmap != nil:
+			panic("unimplemented")
+		}
+	}
+
+	type data struct {
+		Series    map[string]any `json:"series"`
+		Timestamp int64          `json:"timestamp"`
+	}
+
+	if err := json.NewEncoder(w).Encode(struct {
+		Event string `json:"event"`
+		Data  data   `json:"data"`
+	}{
+		Event: "metrics",
+		Data: data{
+			Series:    m,
+			Timestamp: now.UnixMilli(),
+		},
+	}); err != nil {
+		return 0, fmt.Errorf("failed to write/convert metrics values to json: %v", err)
+	}
+
+	nplots := int64(len(pl.rtPlots) + len(pl.userPlots))
+	return nplots, nil
+}
diff --git a/internal/plot/plot_cgo.go b/internal/plot/plot_cgo.go
new file mode 100644
index 0000000..f135534
--- /dev/null
+++ b/internal/plot/plot_cgo.go
@@ -0,0 +1,41 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/cgo/go-to-c-calls:calls",
+	},
+	getvalues: func() getvalues {
+		// TODO also show cgo calls per second ?
+		deltacalls := delta[uint64]()
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			calls := samples[idx_cgo_go_to_c_calls_calls].Value.Uint64()
+
+			return []uint64{deltacalls(calls)}
+		}
+	},
+	layout: Scatter{
+		Name:  "cgo",
+		Tags:  []string{tagMisc},
+		Title: "CGO Calls",
+		Type:  "bar",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "calls",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "calls from go to c",
+				Unitfmt: "%{y}",
+				Color:   "red",
+			},
+		},
+		InfoText: "Shows the count of calls made from Go to C by the current process, per unit of time. Uses <b>/cgo/go-to-c-calls:calls</b>",
+	},
+})
diff --git a/internal/plot/plot_cpu_gc.go b/internal/plot/plot_cpu_gc.go
new file mode 100644
index 0000000..4fc4e9c
--- /dev/null
+++ b/internal/plot/plot_cpu_gc.go
@@ -0,0 +1,64 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/cpu/classes/gc/mark/assist:cpu-seconds",
+		"/cpu/classes/gc/mark/dedicated:cpu-seconds",
+		"/cpu/classes/gc/mark/idle:cpu-seconds",
+		"/cpu/classes/gc/pause:cpu-seconds",
+	},
+	getvalues: func() getvalues {
+		rateassist := rate[float64]()
+		ratededicated := rate[float64]()
+		rateidle := rate[float64]()
+		ratepause := rate[float64]()
+
+		return func(now time.Time, samples []metrics.Sample) any {
+			assist := samples[idx_cpu_classes_gc_mark_assist_cpu_seconds].Value.Float64()
+			dedicated := samples[idx_cpu_classes_gc_mark_dedicated_cpu_seconds].Value.Float64()
+			idle := samples[idx_cpu_classes_gc_mark_idle_cpu_seconds].Value.Float64()
+			pause := samples[idx_cpu_classes_gc_pause_cpu_seconds].Value.Float64()
+
+			return []float64{
+				rateassist(now, assist),
+				ratededicated(now, dedicated),
+				rateidle(now, idle),
+				ratepause(now, pause),
+			}
+		}
+	},
+	layout: Scatter{
+		Name:   "cpu-gc",
+		Tags:   []tag{tagCPU, tagGC},
+		Title:  "CPU (Garbage Collector)",
+		Type:   "scatter",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title:      "cpu-seconds per seconds",
+				TickSuffix: "s",
+			},
+		},
+		Subplots: []Subplot{
+			{Name: "mark assist", Unitfmt: "%{y:.4s}s"},
+			{Name: "mark dedicated", Unitfmt: "%{y:.4s}s"},
+			{Name: "mark idle", Unitfmt: "%{y:.4s}s"},
+			{Name: "pause", Unitfmt: "%{y:.4s}s"},
+		},
+
+		InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable.
+All this metrics are overestimates, and not directly comparable to system CPU time measurements. Compare only with other /cpu/classes metrics.
+
+<i>mark assist</i> is <b>/cpu/classes/gc/mark/assist</b>, estimated total CPU time goroutines spent performing GC tasks to assist the GC and prevent it from falling behind the application.
+<i>mark dedicated</i> is <b>/cpu/classes/gc/mark/dedicated</b>, Estimated total CPU time spent performing GC tasks on processors (as defined by GOMAXPROCS) dedicated to those tasks.
+<i>mark idle</i> is <b>/cpu/classes/gc/mark/idle</b>, estimated total CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for.
+<i>pause</i> is <b>/cpu/classes/gc/pause</b>, estimated total CPU time spent with the application paused by the GC.
+
+All metrics are rates in CPU-seconds per second.`,
+	},
+})
diff --git a/internal/plot/plot_cpu_overall.go b/internal/plot/plot_cpu_overall.go
new file mode 100644
index 0000000..3bf583b
--- /dev/null
+++ b/internal/plot/plot_cpu_overall.go
@@ -0,0 +1,68 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/cpu/classes/user:cpu-seconds",
+		"/cpu/classes/scavenge/total:cpu-seconds",
+		"/cpu/classes/idle:cpu-seconds",
+		"/cpu/classes/gc/total:cpu-seconds",
+		"/cpu/classes/total:cpu-seconds",
+	},
+	getvalues: func() getvalues {
+		rateuser := rate[float64]()
+		ratescavenge := rate[float64]()
+		rateidle := rate[float64]()
+		rategctotal := rate[float64]()
+		ratetotal := rate[float64]()
+
+		return func(now time.Time, samples []metrics.Sample) any {
+			user := samples[idx_cpu_classes_user_cpu_seconds].Value.Float64()
+			scavenge := samples[idx_cpu_classes_scavenge_total_cpu_seconds].Value.Float64()
+			idle := samples[idx_cpu_classes_idle_cpu_seconds].Value.Float64()
+			gctotal := samples[idx_cpu_classes_gc_total_cpu_seconds].Value.Float64()
+			total := samples[idx_cpu_classes_total_cpu_seconds].Value.Float64()
+
+			return []float64{
+				rateuser(now, user),
+				ratescavenge(now, scavenge),
+				rateidle(now, idle),
+				rategctotal(now, gctotal),
+				ratetotal(now, total),
+			}
+		}
+	},
+	layout: Scatter{
+		Name:   "cpu-overall",
+		Tags:   []tag{tagCPU},
+		Title:  "CPU (Overall)",
+		Type:   "bar",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			BarMode: "stack",
+			Yaxis: ScatterYAxis{
+				Title:      "cpu-seconds / second",
+				TickSuffix: "s",
+			},
+		},
+		Subplots: []Subplot{
+			{Unitfmt: "%{y:.4s}s", Type: "bar", Name: "user"},
+			{Unitfmt: "%{y:.4s}s", Type: "bar", Name: "scavenge"},
+			{Unitfmt: "%{y:.4s}s", Type: "bar", Name: "idle"},
+			{Unitfmt: "%{y:.4s}s", Type: "bar", Name: "gc total"},
+			{Unitfmt: "%{y:.4s}s", Type: "scatter", Name: "total"},
+		},
+		InfoText: `Shows the fraction of CPU spent in your code vs. runtime vs. wasted. Helps track overall utilization and potential headroom.
+<i>user is</i> the rate of <b>/cpu/classes/user:cpu-seconds</b>, the CPU time spent running user Go code.
+<i>scavenge is</i> the rate of <b>/cpu/classes/scavenge:cpu-seconds</b>, the CPU time spent performing tasks that return unused memory to the OS.
+<i>idle is</i> the rate of <b>/cpu/classes/idle:cpu-seconds</b>, the CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for.
+<i>gc total is</i> the rate of <b>/cpu/classes/gc/total:cpu-seconds</b>, the CPU time spent performing GC tasks (sum of all metrics in <b>/cpu/classes/gc</b>)
+<i>total is</i> the rate of <b>/cpu/classes/total:cpu-seconds</b>, the available CPU time for user Go code or the Go runtime, as defined by GOMAXPROCS. In other words, GOMAXPROCS integrated over the wall-clock duration this process has been executing for.
+
+All metrics are rates in CPU-seconds per second.`,
+	},
+})
diff --git a/internal/plot/plot_cpu_scavenger.go b/internal/plot/plot_cpu_scavenger.go
new file mode 100644
index 0000000..8029ad5
--- /dev/null
+++ b/internal/plot/plot_cpu_scavenger.go
@@ -0,0 +1,57 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/cpu/classes/scavenge/assist:cpu-seconds",
+		"/cpu/classes/scavenge/background:cpu-seconds",
+	},
+	getvalues: func() getvalues {
+		rateassist := rate[float64]()
+		ratebackground := rate[float64]()
+
+		return func(now time.Time, samples []metrics.Sample) any {
+			assist := samples[idx_cpu_classes_scavenge_assist_cpu_seconds].Value.Float64()
+			background := samples[idx_cpu_classes_scavenge_background_cpu_seconds].Value.Float64()
+			return []float64{
+				rateassist(now, assist),
+				ratebackground(now, background),
+			}
+		}
+	},
+	layout: Scatter{
+		Name:   "cpu-scavenger",
+		Tags:   []tag{tagCPU, tagGC},
+		Title:  "CPU (Scavenger)",
+		Type:   "bar",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			BarMode: "stack",
+			Yaxis: ScatterYAxis{
+				Title:      "cpu-seconds / second",
+				TickSuffix: "s",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "assist",
+				Unitfmt: "%{y:.4s}s",
+				Type:    "bar",
+			},
+			{
+				Name:    "background",
+				Unitfmt: "%{y:.4s}s",
+				Type:    "bar",
+			},
+		},
+		InfoText: `Breakdown of how the GC scavenger returns memory to the OS (eagerly vs background).
+<i>assist is</i> the rate of <b>/cpu/classes/scavenge/assist</b>, the CPU time spent returning unused memory eagerly in response to memory pressure.
+<i>background is</i> the rate of <b>/cpu/classes/scavenge/background</b>, the CPU time spent performing background tasks to return unused memory to the OS.
+
+Both metrics are rates in CPU-seconds per second.`,
+	},
+})
diff --git a/internal/plot/plot_gc.go b/internal/plot/plot_gc.go
new file mode 100644
index 0000000..b06b742
--- /dev/null
+++ b/internal/plot/plot_gc.go
@@ -0,0 +1,61 @@
+package plot
+
+import (
+	"math"
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/gomemlimit:bytes",
+		"/gc/heap/live:bytes",
+		"/gc/heap/goal:bytes",
+		"/memory/classes/total:bytes",
+		"/memory/classes/heap/released:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			memLimit := samples[idx_gc_gomemlimit_bytes].Value.Uint64()
+			heapLive := samples[idx_gc_heap_live_bytes].Value.Uint64()
+			heapGoal := samples[idx_gc_heap_goal_bytes].Value.Uint64()
+			memTotal := samples[idx_memory_classes_total_bytes].Value.Uint64()
+			heapReleased := samples[idx_memory_classes_heap_released_bytes].Value.Uint64()
+
+			if memLimit == math.MaxInt64 {
+				memLimit = 0
+			}
+
+			return []uint64{
+				memLimit,
+				memTotal - heapReleased,
+				heapLive,
+				heapGoal,
+			}
+		}
+	},
+	layout: Scatter{
+		Name:   "garbage collection",
+		Tags:   []tag{tagGC},
+		Title:  "GC Memory Summary",
+		Type:   "scatter",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title:      "bytes",
+				TickSuffix: "B",
+			},
+		},
+		Subplots: []Subplot{
+			{Name: "memory limit", Unitfmt: "%{y:.4s}B"},
+			{Name: "in-use memory", Unitfmt: "%{y:.4s}B"},
+			{Name: "heap live", Unitfmt: "%{y:.4s}B"},
+			{Name: "heap goal", Unitfmt: "%{y:.4s}B"},
+		},
+		InfoText: `
+<i>Memory limit</i> is <b>/gc/gomemlimit:bytes</b>, the Go runtime memory limit configured by the user (via GOMEMLIMIT or debug.SetMemoryLimt), otherwise 0. 
+<i>In-use memory</i> is the total mapped memory minus released heap memory (<b>/memory/classes/total - /memory/classes/heap/released</b>).
+<i>Heap live</i> is <b>/gc/heap/live:bytes</b>, heap memory occupied by live objects.  
+<i>Heap goal</i> is <b>/gc/heap/goal:bytes</b>, the heap size target at the end of each GC cycle.`,
+	},
+})
diff --git a/internal/plot/plot_gc_cycles.go b/internal/plot/plot_gc_cycles.go
new file mode 100644
index 0000000..1b55a3f
--- /dev/null
+++ b/internal/plot/plot_gc_cycles.go
@@ -0,0 +1,45 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/cycles/automatic:gc-cycles",
+		"/gc/cycles/forced:gc-cycles",
+		"/gc/cycles/total:gc-cycles",
+	},
+	getvalues: func() getvalues {
+		deltaauto := delta[uint64]()
+		deltaforced := delta[uint64]()
+		deltatotal := delta[uint64]()
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			auto := samples[idx_gc_cycles_automatic_gc_cycles].Value.Uint64()
+			forced := samples[idx_gc_cycles_forced_gc_cycles].Value.Uint64()
+			total := samples[idx_gc_cycles_total_gc_cycles].Value.Uint64()
+
+			return []uint64{deltaauto(auto), deltaforced(forced), deltatotal(total)}
+		}
+	},
+	layout: Scatter{
+		Name:  "gc-cycles",
+		Tags:  []tag{tagGC},
+		Title: "Completed GC Cycles",
+		Type:  "bar",
+		Layout: ScatterLayout{
+			BarMode: "stack",
+			Yaxis: ScatterYAxis{
+				Title: "cycles",
+			},
+		},
+		Subplots: []Subplot{
+			{Unitfmt: "%{y}", Type: "bar", Name: "automatic"},
+			{Unitfmt: "%{y}", Type: "bar", Name: "forced"},
+			{Unitfmt: "%{y}", Type: "scatter", Name: "total"},
+		},
+		InfoText: `Number of completed GC cycles, either forced of generated by the Go runtime.`,
+	},
+})
diff --git a/internal/plot/plot_gc_scan.go b/internal/plot/plot_gc_scan.go
new file mode 100644
index 0000000..3979332
--- /dev/null
+++ b/internal/plot/plot_gc_scan.go
@@ -0,0 +1,60 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/scan/globals:bytes",
+		"/gc/scan/heap:bytes",
+		"/gc/scan/stack:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			globals := samples[idx_gc_scan_globals_bytes].Value.Uint64()
+			heap := samples[idx_gc_scan_heap_bytes].Value.Uint64()
+			stack := samples[idx_gc_scan_stack_bytes].Value.Uint64()
+
+			return []uint64{globals, heap, stack}
+		}
+	},
+	layout: Scatter{
+		Name:   "gc-scan",
+		Tags:   []tag{tagGC},
+		Title:  "GC Scan",
+		Type:   "bar",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			BarMode: "stack",
+			Yaxis: ScatterYAxis{
+				TickSuffix: "B",
+				Title:      "bytes",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "scannable globals",
+				Unitfmt: "%{y:.4s}B",
+				Type:    "bar",
+			},
+			{
+				Name:    "scannable heap",
+				Unitfmt: "%{y:.4s}B",
+				Type:    "bar",
+			},
+			{
+				Name:    "scanned stack",
+				Unitfmt: "%{y:.4s}B",
+				Type:    "bar",
+			},
+		},
+		InfoText: `
+This plot shows the amount of memory that is scannable by the GC.
+<i>scannable globals</i> is <b>/gc/scan/globals</b>, the total amount of global variable space that is scannable.
+<i>scannable heap</i> is <b>/gc/scan/heap</b>, the total amount of heap space that is scannable.
+<i>scanned stack</i> is <b>/gc/scan/stack</b>, the number of bytes of stack that were scanned last GC cycle.
+`,
+	},
+})
diff --git a/internal/plot/plot_goroutines.go b/internal/plot/plot_goroutines.go
new file mode 100644
index 0000000..0a27a17
--- /dev/null
+++ b/internal/plot/plot_goroutines.go
@@ -0,0 +1,59 @@
+//go:build go1.26
+
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/goroutines:goroutines",
+		"/sched/goroutines-created:goroutines",
+		"/sched/goroutines/not-in-go:goroutines",
+		"/sched/goroutines/runnable:goroutines",
+		"/sched/goroutines/running:goroutines",
+		"/sched/goroutines/waiting:goroutines",
+	},
+	getvalues: func() getvalues {
+		deltaCreated := delta[uint64]()
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			created := deltaCreated(samples[idx_sched_goroutines_created_goroutines].Value.Uint64())
+			goroutines := samples[idx_sched_goroutines_goroutines].Value.Uint64()
+			notInGo := samples[idx_sched_goroutines_not_in_go_goroutines].Value.Uint64()
+			runnable := samples[idx_sched_goroutines_runnable_goroutines].Value.Uint64()
+			running := samples[idx_sched_goroutines_running_goroutines].Value.Uint64()
+			waiting := samples[idx_sched_goroutines_waiting_goroutines].Value.Uint64()
+
+			return []uint64{created, goroutines, notInGo, runnable, running, waiting}
+		}
+	},
+
+	layout: Scatter{
+		Name:  "goroutines",
+		Tags:  []tag{tagScheduler},
+		Title: "Goroutines",
+		Type:  "scatter",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "goroutines",
+			},
+		},
+		Subplots: []Subplot{
+			{Name: "created", Unitfmt: "%{y}", Type: "bar"},
+			{Name: "goroutines", Unitfmt: "%{y}"},
+			{Name: "not in Go", Unitfmt: "%{y}"},
+			{Name: "runnable", Unitfmt: "%{y}"},
+			{Name: "running", Unitfmt: "%{y}"},
+			{Name: "waiting", Unitfmt: "%{y}"},
+		},
+		InfoText: `<i>Goroutines</i> is <b>/sched/goroutines</b>, the count of live goroutines.
+<i>Created</i> is the delta of <b>/sched/goroutines-created</b>, the cumulative number of created goroutines.
+<i>Not in Go</i> is <b>/sched/goroutines/not-in-go</b>, the approximate count of goroutines running or blocked in a system call or cgo call.
+<i>Runnable</i> is <b>/sched/goroutines/runnable</b>, the approximate count of goroutines ready to execute, but not executing.
+<i>Running</i> is <b>/sched/goroutines/running</b>, the approximate count of goroutines executing.
+<i>Waiting</i> is <b>/sched/goroutines/waiting</b>, the approximate count of goroutines waiting on a resource (I/O or sync primitives).`,
+	},
+})
diff --git a/internal/plot/plot_gs_stack_size.go b/internal/plot/plot_gs_stack_size.go
new file mode 100644
index 0000000..eed96d9
--- /dev/null
+++ b/internal/plot/plot_gs_stack_size.go
@@ -0,0 +1,33 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/stack/starting-size:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			stackSize := samples[idx_gc_stack_starting_size_bytes].Value.Uint64()
+			return []uint64{stackSize}
+		}
+	},
+	layout: Scatter{
+		Name:  "gc-stack-size",
+		Tags:  []tag{tagGC},
+		Title: "Goroutines stack starting size",
+		Type:  "scatter",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "bytes",
+			},
+		},
+		Subplots: []Subplot{
+			{Name: "new goroutines stack size", Unitfmt: "%{y:.4s}B"},
+		},
+		InfoText: "Shows the stack size of new goroutines, uses <b>/gc/stack/starting-size:bytes</b>",
+	},
+})
diff --git a/internal/plot/plot_heap_allocs.go b/internal/plot/plot_heap_allocs.go
new file mode 100644
index 0000000..bf50f2e
--- /dev/null
+++ b/internal/plot/plot_heap_allocs.go
@@ -0,0 +1,56 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/heap/allocs:objects",
+		"/gc/heap/frees:objects",
+	},
+	getvalues: func() getvalues {
+		deltaallocs := delta[uint64]()
+		deltafrees := delta[uint64]()
+
+		rateallocs := rate[uint64]()
+		ratefrees := rate[uint64]()
+
+		return func(now time.Time, samples []metrics.Sample) any {
+			curallocs := samples[idx_gc_heap_allocs_objects].Value.Uint64()
+			curfrees := samples[idx_gc_heap_frees_objects].Value.Uint64()
+
+			return []float64{
+				rateallocs(now, deltaallocs(curallocs)),
+				ratefrees(now, deltafrees(curfrees)),
+			}
+		}
+	},
+	layout: Scatter{
+		Name:  "alloc-free-rate",
+		Tags:  []tag{tagGC},
+		Title: "Heap Allocation & Free Rates",
+		Type:  "scatter",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "objects / second",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "allocs/sec",
+				Unitfmt: "%{y:.4s}",
+				Color:   RGBString(66, 133, 244),
+			},
+			{
+				Name:    "frees/sec",
+				Unitfmt: "%{y:.4s}",
+				Color:   RGBString(219, 68, 55),
+			},
+		},
+		InfoText: `
+<i>Allocations per second</i> is the delta, per second, of the cumulative <b>/gc/heap/allocs:objects</b> metric.
+<i>Frees per second</i> is the delta, per second, of the cumulative <b>/gc/heap/frees:objects</b> metric.`,
+	},
+})
diff --git a/internal/plot/plot_heap_details.go b/internal/plot/plot_heap_details.go
new file mode 100644
index 0000000..bd4370b
--- /dev/null
+++ b/internal/plot/plot_heap_details.go
@@ -0,0 +1,63 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/memory/classes/heap/objects:bytes",
+		"/memory/classes/heap/unused:bytes",
+		"/memory/classes/heap/free:bytes",
+		"/memory/classes/heap/released:bytes",
+		"/memory/classes/heap/stacks:bytes",
+		"/gc/heap/goal:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			heapObjects := samples[idx_memory_classes_heap_objects_bytes].Value.Uint64()
+			heapUnused := samples[idx_memory_classes_heap_unused_bytes].Value.Uint64()
+			heapFree := samples[idx_memory_classes_heap_free_bytes].Value.Uint64()
+			heapReleased := samples[idx_memory_classes_heap_released_bytes].Value.Uint64()
+			heapStacks := samples[idx_memory_classes_heap_stacks_bytes].Value.Uint64()
+			nextGC := samples[idx_gc_heap_goal_bytes].Value.Uint64()
+
+			heapIdle := heapReleased + heapFree
+			heapInUse := heapObjects + heapUnused
+			heapSys := heapInUse + heapIdle
+
+			return []uint64{
+				heapSys,
+				heapObjects,
+				heapStacks,
+				nextGC,
+			}
+		}
+	},
+
+	layout: Scatter{
+		Name:   "heap (details)",
+		Tags:   []tag{tagGC},
+		Title:  "Heap (details)",
+		Type:   "scatter",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title:      "bytes",
+				TickSuffix: "B",
+			},
+		},
+		Subplots: []Subplot{
+			{Unitfmt: "%{y:.4s}B", Name: "heap sys"},
+			{Unitfmt: "%{y:.4s}B", Name: "heap objects"},
+			{Unitfmt: "%{y:.4s}B", Name: "heap stacks"},
+			{Unitfmt: "%{y:.4s}B", Name: "heap goal"},
+		},
+		InfoText: `
+<i>Heap</i> sys is <b>/memory/classes/heap/{objects + unused + released + free}</b>. It's an estimate of all the heap memory obtained from the OS.
+<i>Heap objects</i> is <b>/memory/classes/heap/objects</b>, the memory occupied by live objects and dead objects that have not yet been marked free by the GC.
+<i>Heap stacks</i> is <b>/memory/classes/heap/stacks</b>, the memory used for stack space.
+<i>Heap goal</i> is <b>gc/heap/goal</b>, the heap size target for the end of the GC cycle.`,
+	},
+})
diff --git a/internal/plot/plot_live_bytes.go b/internal/plot/plot_live_bytes.go
new file mode 100644
index 0000000..864f342
--- /dev/null
+++ b/internal/plot/plot_live_bytes.go
@@ -0,0 +1,41 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/heap/allocs:bytes",
+		"/gc/heap/frees:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			allocBytes := samples[idx_gc_heap_allocs_bytes].Value.Uint64()
+			freedBytes := samples[idx_gc_heap_frees_bytes].Value.Uint64()
+
+			return []uint64{allocBytes - freedBytes}
+		}
+	},
+	layout: Scatter{
+		Name:   "live-bytes",
+		Tags:   []tag{tagGC},
+		Title:  "Live Bytes in Heap",
+		Type:   "bar",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "bytes",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "live bytes",
+				Unitfmt: "%{y:.4s}B",
+				Color:   RGBString(135, 182, 218),
+			},
+		},
+		InfoText: `<i>Live bytes</i> is <b>/gc/heap/allocs - /gc/heap/frees</b>. It's the number of bytes currently allocated (and not yet GC'ec) to the heap by the application.`,
+	},
+})
diff --git a/internal/plot/plot_live_objects.go b/internal/plot/plot_live_objects.go
new file mode 100644
index 0000000..ac02b8f
--- /dev/null
+++ b/internal/plot/plot_live_objects.go
@@ -0,0 +1,39 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/heap/objects:objects",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			gcHeapObjects := samples[idx_gc_heap_objects_objects].Value.Uint64()
+
+			return []uint64{gcHeapObjects}
+		}
+	},
+	layout: Scatter{
+		Name:   "live-objects",
+		Tags:   []tag{tagGC},
+		Title:  "Live Objects in Heap",
+		Type:   "bar",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "objects",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "live objects",
+				Unitfmt: "%{y:.4s}",
+				Color:   RGBString(255, 195, 128),
+			},
+		},
+		InfoText: `<i>Live objects</i> is <b>/gc/heap/objects</b>. It's the number of objects, live or unswept, occupying heap memory.`,
+	},
+})
diff --git a/internal/plot/plot_memory_classes.go b/internal/plot/plot_memory_classes.go
new file mode 100644
index 0000000..e58f773
--- /dev/null
+++ b/internal/plot/plot_memory_classes.go
@@ -0,0 +1,55 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/memory/classes/os-stacks:bytes",
+		"/memory/classes/other:bytes",
+		"/memory/classes/profiling/buckets:bytes",
+		"/memory/classes/total:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			osStacks := samples[idx_memory_classes_os_stacks_bytes].Value.Uint64()
+			other := samples[idx_memory_classes_other_bytes].Value.Uint64()
+			profBuckets := samples[idx_memory_classes_profiling_buckets_bytes].Value.Uint64()
+			total := samples[idx_memory_classes_total_bytes].Value.Uint64()
+
+			return []uint64{
+				osStacks,
+				other,
+				profBuckets,
+				total,
+			}
+		}
+	},
+	layout: Scatter{
+		Name:   "memory-classes",
+		Tags:   []tag{tagGC},
+		Title:  "Memory classes",
+		Type:   "scatter",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title:      "bytes",
+				TickSuffix: "B",
+			},
+		},
+		Subplots: []Subplot{
+			{Unitfmt: "%{y:.4s}B", Name: "os stacks"},
+			{Unitfmt: "%{y:.4s}B", Name: "other"},
+			{Unitfmt: "%{y:.4s}B", Name: "profiling buckets"},
+			{Unitfmt: "%{y:.4s}B", Name: "total"},
+		},
+
+		InfoText: `
+<i>OS stacks</i> is <b>/memory/classes/os-stacks</b>, stack memory allocated by the underlying operating system.
+<i>Other</i> is <b>/memory/classes/other</b>, memory used by execution trace buffers, structures for debugging the runtime, finalizer and profiler specials, and more.
+<i>Profiling buckets</i> is <b>/memory/classes/profiling/buckets</b>, memory that is used by the stack trace hash map used for profiling.
+<i>Total</i> is <b>/memory/classes/total</b>, all memory mapped by the Go runtime into the current process as read-write.`,
+	},
+})
diff --git a/internal/plot/plot_mspan_mcache.go b/internal/plot/plot_mspan_mcache.go
new file mode 100644
index 0000000..b3f8f83
--- /dev/null
+++ b/internal/plot/plot_mspan_mcache.go
@@ -0,0 +1,50 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/memory/classes/metadata/mspan/inuse:bytes",
+		"/memory/classes/metadata/mspan/free:bytes",
+		"/memory/classes/metadata/mcache/inuse:bytes",
+		"/memory/classes/metadata/mcache/free:bytes",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			mspanInUse := samples[idx_memory_classes_metadata_mspan_inuse_bytes].Value.Uint64()
+			mspanSys := samples[idx_memory_classes_metadata_mspan_free_bytes].Value.Uint64()
+			mcacheInUse := samples[idx_memory_classes_metadata_mcache_inuse_bytes].Value.Uint64()
+			mcacheSys := samples[idx_memory_classes_metadata_mcache_free_bytes].Value.Uint64()
+
+			return []uint64{mspanInUse, mspanSys, mcacheInUse, mcacheSys}
+		}
+	},
+	layout: Scatter{
+		Name:   "mspan-mcache",
+		Tags:   []tag{tagGC},
+		Title:  "MSpan/MCache",
+		Type:   "scatter",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title:      "bytes",
+				TickSuffix: "B",
+			},
+		},
+		Subplots: []Subplot{
+			{Unitfmt: "%{y:.4s}B", Name: "mspan in-use"},
+			{Unitfmt: "%{y:.4s}B", Name: "mspan free"},
+			{Unitfmt: "%{y:.4s}B", Name: "mcache in-use"},
+			{Unitfmt: "%{y:.4s}B", Name: "mcache free"},
+		},
+		InfoText: `
+<i>Mspan in-use</i> is <b>/memory/classes/metadata/mspan/inuse</b>, the memory that is occupied by runtime mspan structures that are currently being used.
+<i>Mspan free</i> is <b>/memory/classes/metadata/mspan/free</b>, the memory that is reserved for runtime mspan structures, but not in-use.
+<i>Mcache in-use</i> is <b>/memory/classes/metadata/mcache/inuse</b>, the memory that is occupied by runtime mcache structures that are currently being used.
+<i>Mcache free</i> is <b>/memory/classes/metadata/mcache/free</b>, the memory that is reserved for runtime mcache structures, but not in-use.
+`,
+	},
+})
diff --git a/internal/plot/plot_mutex_wait.go b/internal/plot/plot_mutex_wait.go
new file mode 100644
index 0000000..1a136e4
--- /dev/null
+++ b/internal/plot/plot_mutex_wait.go
@@ -0,0 +1,46 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sync/mutex/wait/total:seconds",
+	},
+	getvalues: func() getvalues {
+		ratemutexwait := rate[float64]()
+
+		return func(now time.Time, samples []metrics.Sample) any {
+			mutexwait := ratemutexwait(now, samples[idx_sync_mutex_wait_total_seconds].Value.Float64())
+
+			return []float64{mutexwait}
+		}
+	},
+	layout: Scatter{
+		Name:   "mutex-wait",
+		Tags:   []tag{tagMisc},
+		Title:  "Mutex wait time",
+		Type:   "bar",
+		Events: "lastgc",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title:      "seconds / second",
+				TickSuffix: "s",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "mutex wait",
+				Unitfmt: "%{y:.4s}s",
+				Type:    "bar",
+			},
+		},
+
+		InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable.
+<i>mutex wait</i> is <b>/sync/mutex/wait/total</b>, approximate cumulative time goroutines have spent blocked on a sync.Mutex or sync.RWMutex.
+
+This metric is useful for identifying global changes in lock contention. Collect a mutex or block profile using the runtime/pprof package for more detailed contention data.`,
+	},
+})
diff --git a/internal/plot/plot_runnable_time.go b/internal/plot/plot_runnable_time.go
new file mode 100644
index 0000000..293cb85
--- /dev/null
+++ b/internal/plot/plot_runnable_time.go
@@ -0,0 +1,55 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/latencies:seconds",
+	},
+	getvalues: func() getvalues {
+		histfactor := 0
+		counts := [maxBuckets]uint64{}
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			hist := samples[idx_sched_latencies_seconds].Value.Float64Histogram()
+			if histfactor == 0 {
+				histfactor = downsampleFactor(len(hist.Buckets), maxBuckets)
+			}
+
+			return downsampleCounts(hist, histfactor, counts[:])
+		}
+	},
+	layout: func(samples []metrics.Sample) Heatmap {
+		hist := samples[idx_sched_latencies_seconds].Value.Float64Histogram()
+		histfactor := downsampleFactor(len(hist.Buckets), maxBuckets)
+		buckets := downsampleBuckets(hist, histfactor)
+
+		return Heatmap{
+			Name:       "runnable-time",
+			Tags:       []tag{tagScheduler},
+			Title:      "Time Goroutines Spend in 'Runnable' state",
+			Type:       "heatmap",
+			UpdateFreq: 5,
+			Colorscale: GreenShades,
+			Buckets:    floatseq(len(buckets)),
+			CustomData: buckets,
+			Hover: HeapmapHover{
+				YName: "duration",
+				YUnit: "duration",
+				ZName: "goroutines",
+			},
+			Layout: HeatmapLayout{
+				YAxis: HeatmapYaxis{
+					Title:    "duration",
+					TickMode: "array",
+					TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
+					TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
+				},
+			},
+			InfoText: `This heatmap shows the distribution of the time goroutines have spent in the scheduler in a runnable state before actually running, uses <b>/sched/latencies:seconds</b>.`,
+		}
+	},
+})
diff --git a/internal/plot/plot_size_classes.go b/internal/plot/plot_size_classes.go
new file mode 100644
index 0000000..7332642
--- /dev/null
+++ b/internal/plot/plot_size_classes.go
@@ -0,0 +1,71 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/gc/heap/allocs-by-size:bytes",
+		"/gc/heap/frees-by-size:bytes",
+	},
+	getvalues: func() getvalues {
+		var sizeClasses []uint64
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			allocsBySize := samples[idx_gc_heap_allocs_by_size_bytes].Value.Float64Histogram()
+			freesBySize := samples[idx_gc_heap_frees_by_size_bytes].Value.Float64Histogram()
+
+			if sizeClasses == nil {
+				sizeClasses = make([]uint64, len(allocsBySize.Counts))
+			}
+
+			for i := range sizeClasses {
+				sizeClasses[i] = allocsBySize.Counts[i] - freesBySize.Counts[i]
+			}
+
+			return sizeClasses
+		}
+	},
+	layout: func(samples []metrics.Sample) Heatmap {
+		// Perform a sanity check on the number of buckets on the 'allocs' and
+		// 'frees' size classes histograms. Statsviz plots a single histogram based
+		// on those 2 so we want them to have the same number of buckets, which
+		// should be true.
+		allocsBySize := samples[idx_gc_heap_allocs_by_size_bytes].Value.Float64Histogram()
+		freesBySize := samples[idx_gc_heap_frees_by_size_bytes].Value.Float64Histogram()
+		if len(allocsBySize.Buckets) != len(freesBySize.Buckets) {
+			panic("different number of buckets in allocs and frees size classes histograms")
+		}
+
+		// No downsampling for the size classes histogram (factor=1) but we still
+		// need to adapt boundaries for plotly heatmaps.
+		buckets := downsampleBuckets(allocsBySize, 1)
+
+		return Heatmap{
+			Name:       "size-classes",
+			Tags:       []tag{tagGC},
+			Title:      "Size Classes",
+			Type:       "heatmap",
+			UpdateFreq: 5,
+			Colorscale: BlueShades,
+			Buckets:    floatseq(len(buckets)),
+			CustomData: buckets,
+			Hover: HeapmapHover{
+				YName: "size class",
+				YUnit: "bytes",
+				ZName: "objects",
+			},
+			InfoText: `This heatmap shows the distribution of size classes, using <b>/gc/heap/allocs-by-size</b> and <b>/gc/heap/frees-by-size</b>.`,
+			Layout: HeatmapLayout{
+				YAxis: HeatmapYaxis{
+					Title:    "size class",
+					TickMode: "array",
+					TickVals: []float64{1, 9, 17, 25, 31, 37, 43, 50, 58, 66},
+					TickText: []float64{1 << 4, 1 << 7, 1 << 8, 1 << 9, 1 << 10, 1 << 11, 1 << 12, 1 << 13, 1 << 14, 1 << 15},
+				},
+			},
+		}
+	},
+})
diff --git a/internal/plot/plot_stopping_pauses_gc.go b/internal/plot/plot_stopping_pauses_gc.go
new file mode 100644
index 0000000..8f6515a
--- /dev/null
+++ b/internal/plot/plot_stopping_pauses_gc.go
@@ -0,0 +1,58 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/pauses/stopping/gc:seconds",
+	},
+	getvalues: func() getvalues {
+		histfactor := 0
+		counts := [maxBuckets]uint64{}
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			hist := samples[idx_sched_pauses_stopping_gc_seconds].Value.Float64Histogram()
+			if histfactor == 0 {
+				histfactor = downsampleFactor(len(hist.Buckets), maxBuckets)
+			}
+
+			return downsampleCounts(hist, histfactor, counts[:])
+		}
+	},
+	layout: func(samples []metrics.Sample) Heatmap {
+		hist := samples[idx_sched_pauses_stopping_gc_seconds].Value.Float64Histogram()
+		histfactor := downsampleFactor(len(hist.Buckets), maxBuckets)
+		buckets := downsampleBuckets(hist, histfactor)
+
+		return Heatmap{
+			Name:       "stopping-pauses-gc",
+			Tags:       []tag{tagScheduler, tagGC},
+			Title:      "Stop-the-world Stopping Latencies (GC)",
+			Type:       "heatmap",
+			UpdateFreq: 5,
+			Colorscale: GreenShades,
+			Buckets:    floatseq(len(buckets)),
+			CustomData: buckets,
+			Hover: HeapmapHover{
+				YName: "stopping duration",
+				YUnit: "duration",
+				ZName: "pauses",
+			},
+			Layout: HeatmapLayout{
+				YAxis: HeatmapYaxis{
+					Title:    "stopping duration",
+					TickMode: "array",
+					TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
+					TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
+				},
+			},
+			InfoText: `This heatmap shows the distribution of individual <b>GC-related</b> stop-the-world <i>stopping latencies</i>.
+This is the time it takes from deciding to stop the world until all Ps are stopped.
+During this time, some threads may be executing.
+Uses <b>/sched/pauses/stopping/gc:seconds</b>.`,
+		}
+	},
+})
diff --git a/internal/plot/plot_stopping_pauses_other.go b/internal/plot/plot_stopping_pauses_other.go
new file mode 100644
index 0000000..78902ac
--- /dev/null
+++ b/internal/plot/plot_stopping_pauses_other.go
@@ -0,0 +1,58 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/pauses/stopping/other:seconds",
+	},
+	getvalues: func() getvalues {
+		histfactor := 0
+		counts := [maxBuckets]uint64{}
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			hist := samples[idx_sched_pauses_stopping_other_seconds].Value.Float64Histogram()
+			if histfactor == 0 {
+				histfactor = downsampleFactor(len(hist.Buckets), maxBuckets)
+			}
+
+			return downsampleCounts(hist, histfactor, counts[:])
+		}
+	},
+	layout: func(samples []metrics.Sample) Heatmap {
+		hist := samples[idx_sched_pauses_stopping_other_seconds].Value.Float64Histogram()
+		histfactor := downsampleFactor(len(hist.Buckets), maxBuckets)
+		buckets := downsampleBuckets(hist, histfactor)
+
+		return Heatmap{
+			Name:       "stopping-pauses-other",
+			Tags:       []tag{tagScheduler},
+			Title:      "Stop-the-world Stopping Latencies (Other)",
+			Type:       "heatmap",
+			UpdateFreq: 5,
+			Colorscale: GreenShades,
+			Buckets:    floatseq(len(buckets)),
+			CustomData: buckets,
+			Hover: HeapmapHover{
+				YName: "stopping duration",
+				YUnit: "duration",
+				ZName: "pauses",
+			},
+			Layout: HeatmapLayout{
+				YAxis: HeatmapYaxis{
+					Title:    "stopping duration",
+					TickMode: "array",
+					TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
+					TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
+				},
+			},
+			InfoText: `This heatmap shows the distribution of individual <b>non-GC-related</b> stop-the-world <i>stopping latencies</i>.
+This is the time it takes from deciding to stop the world until all Ps are stopped.
+This is a subset of the total non-GC-related stop-the-world time. During this time, some threads may be executing.
+Uses <b>/sched/pauses/stopping/other:seconds</b>.`,
+		}
+	},
+})
diff --git a/internal/plot/plot_threads.go b/internal/plot/plot_threads.go
new file mode 100644
index 0000000..adfcbc3
--- /dev/null
+++ b/internal/plot/plot_threads.go
@@ -0,0 +1,39 @@
+//go:build go1.26
+
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/threads/total:threads",
+	},
+	getvalues: func() getvalues {
+		return func(_ time.Time, samples []metrics.Sample) any {
+			threads := samples[idx_sched_threads_total_threads].Value.Uint64()
+
+			return []uint64{threads}
+		}
+	},
+	layout: Scatter{
+		Name:  "threads",
+		Tags:  []tag{tagScheduler},
+		Title: "Threads",
+		Type:  "scatter",
+		Layout: ScatterLayout{
+			Yaxis: ScatterYAxis{
+				Title: "bytes",
+			},
+		},
+		Subplots: []Subplot{
+			{
+				Name:    "threads",
+				Unitfmt: "%{y}",
+			},
+		},
+		InfoText: "Shows the current count of live threads that are owned by the Go runtime. Uses <b>/sched/threads/total:threads</b>",
+	},
+})
diff --git a/internal/plot/plot_total_pauses_gc.go b/internal/plot/plot_total_pauses_gc.go
new file mode 100644
index 0000000..55f267e
--- /dev/null
+++ b/internal/plot/plot_total_pauses_gc.go
@@ -0,0 +1,58 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/pauses/total/gc:seconds",
+	},
+	getvalues: func() getvalues {
+		histfactor := 0
+		counts := [maxBuckets]uint64{}
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			hist := samples[idx_sched_pauses_total_gc_seconds].Value.Float64Histogram()
+			if histfactor == 0 {
+				histfactor = downsampleFactor(len(hist.Buckets), maxBuckets)
+			}
+
+			return downsampleCounts(hist, histfactor, counts[:])
+		}
+	},
+	layout: func(samples []metrics.Sample) Heatmap {
+		hist := samples[idx_sched_pauses_total_gc_seconds].Value.Float64Histogram()
+		histfactor := downsampleFactor(len(hist.Buckets), maxBuckets)
+		buckets := downsampleBuckets(hist, histfactor)
+
+		return Heatmap{
+			Name:       "total-pauses-gc",
+			Tags:       []tag{tagScheduler, tagGC},
+			Title:      "Stop-the-world Pause Latencies (Total)",
+			Type:       "heatmap",
+			UpdateFreq: 5,
+			Colorscale: PinkShades,
+			Buckets:    floatseq(len(buckets)),
+			CustomData: buckets,
+			Hover: HeapmapHover{
+				YName: "pause duration",
+				YUnit: "duration",
+				ZName: "pauses",
+			},
+			Layout: HeatmapLayout{
+				YAxis: HeatmapYaxis{
+					Title:    "pause duration",
+					TickMode: "array",
+					TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
+					TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
+				},
+			},
+			InfoText: `This heatmap shows the distribution of individual <b>GC-related</b> stop-the-world <i>pause latencies</i>.
+This is the time from deciding to stop the world until the world is started again.
+Some of this time is spent getting all threads to stop (this is measured directly in <i>/sched/pauses/stopping/gc:seconds</i>), during which some threads may still be running.
+Uses <b>/sched/pauses/total/gc:seconds</b>.`,
+		}
+	},
+})
diff --git a/internal/plot/plot_total_pauses_other.go b/internal/plot/plot_total_pauses_other.go
new file mode 100644
index 0000000..7a6e7bc
--- /dev/null
+++ b/internal/plot/plot_total_pauses_other.go
@@ -0,0 +1,58 @@
+package plot
+
+import (
+	"runtime/metrics"
+	"time"
+)
+
+var _ = register(description{
+	metrics: []string{
+		"/sched/pauses/total/other:seconds",
+	},
+	getvalues: func() getvalues {
+		histfactor := 0
+		counts := [maxBuckets]uint64{}
+
+		return func(_ time.Time, samples []metrics.Sample) any {
+			hist := samples[idx_sched_pauses_total_other_seconds].Value.Float64Histogram()
+			if histfactor == 0 {
+				histfactor = downsampleFactor(len(hist.Buckets), maxBuckets)
+			}
+
+			return downsampleCounts(hist, histfactor, counts[:])
+		}
+	},
+	layout: func(samples []metrics.Sample) Heatmap {
+		hist := samples[idx_sched_pauses_total_other_seconds].Value.Float64Histogram()
+		histfactor := downsampleFactor(len(hist.Buckets), maxBuckets)
+		buckets := downsampleBuckets(hist, histfactor)
+
+		return Heatmap{
+			Name:       "total-pauses-other",
+			Tags:       []tag{tagScheduler},
+			Title:      "Stop-the-world Pause Latencies (Other)",
+			Type:       "heatmap",
+			UpdateFreq: 5,
+			Colorscale: PinkShades,
+			Buckets:    floatseq(len(buckets)),
+			CustomData: buckets,
+			Hover: HeapmapHover{
+				YName: "pause duration",
+				YUnit: "duration",
+				ZName: "pauses",
+			},
+			Layout: HeatmapLayout{
+				YAxis: HeatmapYaxis{
+					Title:    "pause duration",
+					TickMode: "array",
+					TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86},
+					TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10},
+				},
+			},
+			InfoText: `This heatmap shows the distribution of individual <b>non-GC-related</b> stop-the-world <i>pause latencies</i>.
+This is the time from deciding to stop the world until the world is started again.
+Some of this time is spent getting all threads to stop (measured directly in <i>/sched/pauses/stopping/other:seconds</i>).
+Uses <b>/sched/pauses/total/other:seconds</b>.`,
+		}
+	},
+})
diff --git a/internal/plot/plots.go b/internal/plot/plots.go
deleted file mode 100644
index cd3287a..0000000
--- a/internal/plot/plots.go
+++ /dev/null
@@ -1,1046 +0,0 @@
-package plot
-
-import (
-	"math"
-	"runtime/metrics"
-	"time"
-)
-
-type plotDesc struct {
-	name    string
-	tags    []string
-	metrics []string
-	layout  any
-
-	// make creates the state (support struct) for the plot.
-	make func(indices ...int) metricsGetter
-}
-
-var (
-	plotDescs []plotDesc
-
-	metricDescs = metrics.All()
-	metricIdx   map[string]int
-)
-
-func init() {
-	// We need a first set of sample in order to dimension and process the
-	// heatmaps buckets.
-	samples := make([]metrics.Sample, len(metricDescs))
-	metricIdx = make(map[string]int)
-
-	for i := range samples {
-		samples[i].Name = metricDescs[i].Name
-		metricIdx[samples[i].Name] = i
-	}
-	metrics.Read(samples)
-
-	plotDescs = []plotDesc{
-		{
-			name: "garbage collection",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/gomemlimit:bytes",
-				"/gc/heap/live:bytes",
-				"/gc/heap/goal:bytes",
-				"/memory/classes/total:bytes",
-				"/memory/classes/heap/released:bytes",
-			},
-			layout: garbageCollectionLayout,
-			make:   makeGarbageCollection,
-		},
-		{
-			name: "heap (details)",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/memory/classes/heap/objects:bytes",
-				"/memory/classes/heap/unused:bytes",
-				"/memory/classes/heap/free:bytes",
-				"/memory/classes/heap/released:bytes",
-				"/memory/classes/heap/stacks:bytes",
-				"/gc/heap/goal:bytes",
-			},
-			layout: heapDetailslLayout,
-			make:   makeHeapDetails,
-		},
-		{
-			name: "live-objects",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/heap/objects:objects",
-			},
-			layout: liveObjectsLayout,
-			make:   makeLiveObjects,
-		},
-		{
-			name: "live-bytes",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/heap/allocs:bytes",
-				"/gc/heap/frees:bytes",
-			},
-			layout: liveBytesLayout,
-			make:   makeLiveBytes,
-		},
-		{
-			name: "mspan-mcache",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/memory/classes/metadata/mspan/inuse:bytes",
-				"/memory/classes/metadata/mspan/free:bytes",
-				"/memory/classes/metadata/mcache/inuse:bytes",
-				"/memory/classes/metadata/mcache/free:bytes",
-			},
-			layout: mspanMCacheLayout,
-			make:   makeMSpanMCache,
-		},
-		{
-			name: "goroutines",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/goroutines:goroutines",
-			},
-			layout: goroutinesLayout,
-			make:   makeGoroutines,
-		},
-		{
-			name: "size-classes",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/heap/allocs-by-size:bytes",
-				"/gc/heap/frees-by-size:bytes",
-			},
-			layout: sizeClassesLayout(samples),
-			make:   makeSizeClasses,
-		},
-		{
-			name: "runnable-time",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/latencies:seconds",
-			},
-			layout: runnableTimeLayout(samples),
-			make:   makeRunnableTime,
-		},
-		{
-			name: "sched-events",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/latencies:seconds",
-				"/sched/gomaxprocs:threads",
-			},
-			layout: schedEventsLayout,
-			make:   makeSchedEvents,
-		},
-		{
-			name: "cgo",
-			tags: []string{"misc"},
-			metrics: []string{
-				"/cgo/go-to-c-calls:calls",
-			},
-			layout: cgoLayout,
-			make:   makeCGO,
-		},
-		{
-			name: "gc-stack-size",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/stack/starting-size:bytes",
-			},
-			layout: gcStackSizeLayout,
-			make:   makeGCStackSize,
-		},
-		{
-			name: "gc-cycles",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/cycles/automatic:gc-cycles",
-				"/gc/cycles/forced:gc-cycles",
-				"/gc/cycles/total:gc-cycles",
-			},
-			layout: gcCyclesLayout,
-			make:   makeGCCycles,
-		},
-		{
-			name: "memory-classes",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/memory/classes/os-stacks:bytes",
-				"/memory/classes/other:bytes",
-				"/memory/classes/profiling/buckets:bytes",
-				"/memory/classes/total:bytes",
-			},
-			layout: memoryClassesLayout,
-			make:   makeMemoryClasses,
-		},
-		{
-			name: "cpu-gc",
-			tags: []string{"cpu", "gc"},
-			metrics: []string{
-				"/cpu/classes/gc/mark/assist:cpu-seconds",
-				"/cpu/classes/gc/mark/dedicated:cpu-seconds",
-				"/cpu/classes/gc/mark/idle:cpu-seconds",
-				"/cpu/classes/gc/pause:cpu-seconds",
-			},
-			layout: cpuGCLayout,
-			make:   makeCPUgc,
-		},
-		{
-			name: "cpu-scavenger",
-			tags: []string{"cpu", "gc"},
-			metrics: []string{
-				"/cpu/classes/scavenge/assist:cpu-seconds",
-				"/cpu/classes/scavenge/background:cpu-seconds",
-			},
-			layout: cpuScavengerLayout,
-			make:   makeCPUscavenger,
-		},
-		{
-			name: "cpu-overall",
-			tags: []string{"cpu"},
-			metrics: []string{
-				"/cpu/classes/user:cpu-seconds",
-				"/cpu/classes/scavenge/total:cpu-seconds",
-				"/cpu/classes/idle:cpu-seconds",
-				"/cpu/classes/gc/total:cpu-seconds",
-				"/cpu/classes/total:cpu-seconds",
-			},
-			layout: cpuOverallLayout,
-			make:   makeCPUoverall,
-		},
-		{
-			name: "mutex-wait",
-			tags: []string{"misc"},
-			metrics: []string{
-				"/sync/mutex/wait/total:seconds",
-			},
-			layout: mutexWaitLayout,
-			make:   makeMutexWait,
-		},
-		{
-			name: "gc-scan",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/scan/globals:bytes",
-				"/gc/scan/heap:bytes",
-				"/gc/scan/stack:bytes",
-			},
-			layout: gcScanLayout,
-			make:   makeGCScan,
-		},
-		{
-			name: "alloc-free-rate",
-			tags: []string{"gc"},
-			metrics: []string{
-				"/gc/heap/allocs:objects",
-				"/gc/heap/frees:objects",
-			},
-			layout: allocFreeRatesLayout,
-			make:   makeAllocFreeRates,
-		},
-		{
-			name: "total-pauses-gc",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/pauses/total/gc:seconds",
-			},
-			layout: gcTotalPausesLayout(samples),
-			make:   makeGCTotalPauses,
-		},
-		{
-			name: "total-pauses-other",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/pauses/total/other:seconds",
-			},
-			layout: otherTotalPausesLayout(samples),
-			make:   makeOtherTotalPauses,
-		},
-		{
-			name: "stopping-pauses-gc",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/pauses/stopping/gc:seconds",
-			},
-			layout: gcStoppingPausesLayout(samples),
-			make:   makeGCStoppingPauses,
-		},
-		{
-			name: "stopping-pauses-other",
-			tags: []string{"scheduler"},
-			metrics: []string{
-				"/sched/pauses/stopping/other:seconds",
-			},
-			layout: otherStoppingPausesLayout(samples),
-			make:   makeGCStoppingOther,
-		},
-	}
-}
-
-// garbage collection
-type garbageCollection struct {
-	idxmemlimit     int
-	idxheaplive     int
-	idxheapgoal     int
-	idxmemtotal     int
-	idxheapreleased int
-}
-
-func makeGarbageCollection(indices ...int) metricsGetter {
-	return &garbageCollection{
-		idxmemlimit:     indices[0],
-		idxheaplive:     indices[1],
-		idxheapgoal:     indices[2],
-		idxmemtotal:     indices[3],
-		idxheapreleased: indices[4],
-	}
-}
-
-func (p *garbageCollection) values(samples []metrics.Sample) any {
-	memLimit := samples[p.idxmemlimit].Value.Uint64()
-	heapLive := samples[p.idxheaplive].Value.Uint64()
-	heapGoal := samples[p.idxheapgoal].Value.Uint64()
-	memTotal := samples[p.idxmemtotal].Value.Uint64()
-	heapReleased := samples[p.idxheapreleased].Value.Uint64()
-
-	if memLimit == math.MaxInt64 {
-		memLimit = 0
-	}
-
-	return []uint64{
-		memLimit,
-		memTotal - heapReleased,
-		heapLive,
-		heapGoal,
-	}
-}
-
-// heap (details)
-
-type heapDetails struct {
-	idxobj      int
-	idxunused   int
-	idxfree     int
-	idxreleased int
-	idxstacks   int
-	idxgoal     int
-}
-
-func makeHeapDetails(indices ...int) metricsGetter {
-	return &heapDetails{
-		idxobj:      indices[0],
-		idxunused:   indices[1],
-		idxfree:     indices[2],
-		idxreleased: indices[3],
-		idxstacks:   indices[4],
-		idxgoal:     indices[5],
-	}
-}
-
-func (p *heapDetails) values(samples []metrics.Sample) any {
-	heapObjects := samples[p.idxobj].Value.Uint64()
-	heapUnused := samples[p.idxunused].Value.Uint64()
-	heapFree := samples[p.idxfree].Value.Uint64()
-	heapReleased := samples[p.idxreleased].Value.Uint64()
-	heapStacks := samples[p.idxstacks].Value.Uint64()
-	nextGC := samples[p.idxgoal].Value.Uint64()
-
-	heapIdle := heapReleased + heapFree
-	heapInUse := heapObjects + heapUnused
-	heapSys := heapInUse + heapIdle
-
-	return []uint64{
-		heapSys,
-		heapObjects,
-		heapStacks,
-		nextGC,
-	}
-}
-
-// live objects
-
-type liveObjects struct {
-	idxobjects int
-}
-
-func makeLiveObjects(indices ...int) metricsGetter {
-	return &liveObjects{
-		idxobjects: indices[0],
-	}
-}
-
-func (p *liveObjects) values(samples []metrics.Sample) any {
-	gcHeapObjects := samples[p.idxobjects].Value.Uint64()
-	return []uint64{
-		gcHeapObjects,
-	}
-}
-
-// live bytes
-
-type liveBytes struct {
-	idxallocs int
-	idxfrees  int
-}
-
-func makeLiveBytes(indices ...int) metricsGetter {
-	return &liveBytes{
-		idxallocs: indices[0],
-		idxfrees:  indices[1],
-	}
-}
-
-func (p *liveBytes) values(samples []metrics.Sample) any {
-	allocBytes := samples[p.idxallocs].Value.Uint64()
-	freedBytes := samples[p.idxfrees].Value.Uint64()
-	return []uint64{
-		allocBytes - freedBytes,
-	}
-}
-
-// mspan mcache
-
-type mspanMcache struct {
-	enabled bool
-
-	idxmspanInuse  int
-	idxmspanFree   int
-	idxmcacheInuse int
-	idxmcacheFree  int
-}
-
-func makeMSpanMCache(indices ...int) metricsGetter {
-	return &mspanMcache{
-		idxmspanInuse:  indices[0],
-		idxmspanFree:   indices[1],
-		idxmcacheInuse: indices[2],
-		idxmcacheFree:  indices[3],
-	}
-}
-
-func (p *mspanMcache) values(samples []metrics.Sample) any {
-	mspanInUse := samples[p.idxmspanInuse].Value.Uint64()
-	mspanSys := samples[p.idxmspanFree].Value.Uint64()
-	mcacheInUse := samples[p.idxmcacheInuse].Value.Uint64()
-	mcacheSys := samples[p.idxmcacheFree].Value.Uint64()
-	return []uint64{
-		mspanInUse,
-		mspanSys,
-		mcacheInUse,
-		mcacheSys,
-	}
-}
-
-// goroutines
-
-type goroutines struct {
-	idxgs int
-}
-
-func makeGoroutines(indices ...int) metricsGetter {
-	return &goroutines{
-		idxgs: indices[0],
-	}
-}
-
-func (p *goroutines) values(samples []metrics.Sample) any {
-	return []uint64{samples[p.idxgs].Value.Uint64()}
-}
-
-// size classes
-
-type sizeClasses struct {
-	sizeClasses []uint64
-
-	idxallocs int
-	idxfrees  int
-}
-
-func makeSizeClasses(indices ...int) metricsGetter {
-	return &sizeClasses{
-		idxallocs: indices[0],
-		idxfrees:  indices[1],
-	}
-}
-
-func (p *sizeClasses) values(samples []metrics.Sample) any {
-	allocsBySize := samples[p.idxallocs].Value.Float64Histogram()
-	freesBySize := samples[p.idxfrees].Value.Float64Histogram()
-
-	if p.sizeClasses == nil {
-		p.sizeClasses = make([]uint64, len(allocsBySize.Counts))
-	}
-
-	for i := range p.sizeClasses {
-		p.sizeClasses[i] = allocsBySize.Counts[i] - freesBySize.Counts[i]
-	}
-	return p.sizeClasses
-}
-
-// gc pauses
-
-type gcpauses struct {
-	histfactor int
-	counts     [maxBuckets]uint64
-
-	idxgcpauses int
-}
-
-func makeGCTotalPauses(indices ...int) metricsGetter {
-	return &gcpauses{
-		idxgcpauses: indices[0],
-	}
-}
-
-func (p *gcpauses) values(samples []metrics.Sample) any {
-	if p.histfactor == 0 {
-		gcpauses := samples[p.idxgcpauses].Value.Float64Histogram()
-		p.histfactor = downsampleFactor(len(gcpauses.Buckets), maxBuckets)
-	}
-
-	gcpauses := samples[p.idxgcpauses].Value.Float64Histogram()
-	return downsampleCounts(gcpauses, p.histfactor, p.counts[:])
-}
-
-// runnable time
-
-type runnableTime struct {
-	histfactor int
-	counts     [maxBuckets]uint64
-
-	idxschedlat int
-}
-
-func makeRunnableTime(indices ...int) metricsGetter {
-	return &runnableTime{
-		idxschedlat: indices[0],
-	}
-}
-
-func (p *runnableTime) values(samples []metrics.Sample) any {
-	if p.histfactor == 0 {
-		schedlat := samples[p.idxschedlat].Value.Float64Histogram()
-		p.histfactor = downsampleFactor(len(schedlat.Buckets), maxBuckets)
-	}
-
-	schedlat := samples[p.idxschedlat].Value.Float64Histogram()
-
-	return downsampleCounts(schedlat, p.histfactor, p.counts[:])
-}
-
-// sched events
-
-type schedEvents struct {
-	idxschedlat   int
-	idxGomaxprocs int
-	lasttot       uint64
-}
-
-func makeSchedEvents(indices ...int) metricsGetter {
-	return &schedEvents{
-		idxschedlat:   indices[0],
-		idxGomaxprocs: indices[1],
-		lasttot:       math.MaxUint64,
-	}
-}
-
-// gTrackingPeriod is currently always 8. Guard it behind build tags when that
-// changes. See https://github.com/golang/go/blob/go1.18.4/src/runtime/runtime2.go#L502-L504
-const currentGtrackingPeriod = 8
-
-// TODO show scheduling events per seconds
-func (p *schedEvents) values(samples []metrics.Sample) any {
-	schedlat := samples[p.idxschedlat].Value.Float64Histogram()
-	gomaxprocs := samples[p.idxGomaxprocs].Value.Uint64()
-
-	total := uint64(0)
-	for _, v := range schedlat.Counts {
-		total += v
-	}
-	total *= currentGtrackingPeriod
-
-	curtot := total - p.lasttot
-	if p.lasttot == math.MaxUint64 {
-		// We don't want a big spike at statsviz launch in case the process has
-		// been running for some time and curtot is high.
-		curtot = 0
-	}
-	p.lasttot = total
-
-	ftot := float64(curtot)
-
-	return []float64{
-		ftot,
-		ftot / float64(gomaxprocs),
-	}
-}
-
-// cgo
-
-type cgo struct {
-	idxgo2c  int
-	lastgo2c uint64
-}
-
-func makeCGO(indices ...int) metricsGetter {
-	return &cgo{
-		idxgo2c:  indices[0],
-		lastgo2c: math.MaxUint64,
-	}
-}
-
-// TODO show cgo calls per second
-func (p *cgo) values(samples []metrics.Sample) any {
-	go2c := samples[p.idxgo2c].Value.Uint64()
-	curgo2c := go2c - p.lastgo2c
-	if p.lastgo2c == math.MaxUint64 {
-		curgo2c = 0
-	}
-	p.lastgo2c = go2c
-
-	return []uint64{curgo2c}
-}
-
-// gc stack size
-
-type gcStackSize struct {
-	idxstack int
-}
-
-func makeGCStackSize(indices ...int) metricsGetter {
-	return &gcStackSize{
-		idxstack: indices[0],
-	}
-}
-
-func (p *gcStackSize) values(samples []metrics.Sample) any {
-	stackSize := samples[p.idxstack].Value.Uint64()
-	return []uint64{stackSize}
-}
-
-// gc cycles
-
-type gcCycles struct {
-	idxAutomatic int
-	idxForced    int
-	idxTotal     int
-
-	lastAuto, lastForced, lastTotal uint64
-}
-
-func makeGCCycles(indices ...int) metricsGetter {
-	return &gcCycles{
-		idxAutomatic: indices[0],
-		idxForced:    indices[1],
-		idxTotal:     indices[2],
-	}
-}
-
-func (p *gcCycles) values(samples []metrics.Sample) any {
-	total := samples[p.idxTotal].Value.Uint64()
-	auto := samples[p.idxAutomatic].Value.Uint64()
-	forced := samples[p.idxForced].Value.Uint64()
-
-	if p.lastTotal == 0 {
-		p.lastTotal = total
-		p.lastForced = forced
-		p.lastAuto = auto
-		return []uint64{0, 0}
-	}
-
-	ret := []uint64{
-		auto - p.lastAuto,
-		forced - p.lastForced,
-	}
-
-	p.lastForced = forced
-	p.lastAuto = auto
-
-	return ret
-}
-
-// memory classes
-
-type memoryClasses struct {
-	idxOSStacks    int
-	idxOther       int
-	idxProfBuckets int
-	idxTotal       int
-}
-
-func makeMemoryClasses(indices ...int) metricsGetter {
-	return &memoryClasses{
-		idxOSStacks:    indices[0],
-		idxOther:       indices[1],
-		idxProfBuckets: indices[2],
-		idxTotal:       indices[3],
-	}
-}
-
-func (p *memoryClasses) values(samples []metrics.Sample) any {
-	osStacks := samples[p.idxOSStacks].Value.Uint64()
-	other := samples[p.idxOther].Value.Uint64()
-	profBuckets := samples[p.idxProfBuckets].Value.Uint64()
-	total := samples[p.idxTotal].Value.Uint64()
-
-	return []uint64{
-		osStacks,
-		other,
-		profBuckets,
-		total,
-	}
-}
-
-// cpu (gc)
-
-type CPUgc struct {
-	idxMarkAssist    int
-	idxMarkDedicated int
-	idxMarkIdle      int
-	idxPause         int
-	idxTotal         int
-
-	lastTime time.Time
-
-	lastMarkAssist    float64
-	lastMarkDedicated float64
-	lastMarkIdle      float64
-	lastPause         float64
-}
-
-func makeCPUgc(indices ...int) metricsGetter {
-	return &CPUgc{
-		idxMarkAssist:    indices[0],
-		idxMarkDedicated: indices[1],
-		idxMarkIdle:      indices[2],
-		idxPause:         indices[3],
-	}
-}
-
-func (p *CPUgc) values(samples []metrics.Sample) any {
-	curMarkAssist := samples[p.idxMarkAssist].Value.Float64()
-	curMarkDedicated := samples[p.idxMarkDedicated].Value.Float64()
-	curMarkIdle := samples[p.idxMarkIdle].Value.Float64()
-	curPause := samples[p.idxPause].Value.Float64()
-
-	if p.lastTime.IsZero() {
-		p.lastMarkAssist = curMarkAssist
-		p.lastMarkDedicated = curMarkDedicated
-		p.lastMarkIdle = curMarkIdle
-		p.lastPause = curPause
-		p.lastTime = time.Now()
-
-		return []float64{0, 0, 0, 0, 0}
-	}
-
-	t := time.Since(p.lastTime).Seconds()
-
-	markAssist := (curMarkAssist - p.lastMarkAssist) / t
-	markDedicated := (curMarkDedicated - p.lastMarkDedicated) / t
-	markIdle := (curMarkIdle - p.lastMarkIdle) / t
-	pause := (curPause - p.lastPause) / t
-
-	p.lastMarkAssist = curMarkAssist
-	p.lastMarkDedicated = curMarkDedicated
-	p.lastMarkIdle = curMarkIdle
-	p.lastPause = curPause
-	p.lastTime = time.Now()
-
-	return []float64{
-		markAssist,
-		markDedicated,
-		markIdle,
-		pause,
-	}
-}
-
-// cpu (scavenger)
-
-type cpuScavenger struct {
-	idxScavengeAssist     int
-	idxScavengeBackground int
-
-	lastTime time.Time
-
-	lastScavengeAssist     float64
-	lastScavengeBackground float64
-}
-
-func makeCPUscavenger(indices ...int) metricsGetter {
-	return &cpuScavenger{
-		idxScavengeAssist:     indices[0],
-		idxScavengeBackground: indices[1],
-	}
-}
-
-func (p *cpuScavenger) values(samples []metrics.Sample) any {
-	curScavengeAssist := samples[p.idxScavengeAssist].Value.Float64()
-	curScavengeBackground := samples[p.idxScavengeBackground].Value.Float64()
-
-	if p.lastTime.IsZero() {
-		p.lastScavengeAssist = curScavengeAssist
-		p.lastScavengeBackground = curScavengeBackground
-		p.lastTime = time.Now()
-
-		return []float64{0, 0, 0, 0, 0}
-	}
-
-	t := time.Since(p.lastTime).Seconds()
-
-	scavengeAssist := (curScavengeAssist - p.lastScavengeAssist) / t
-	scavengeBackground := (curScavengeBackground - p.lastScavengeBackground) / t
-
-	p.lastScavengeAssist = curScavengeAssist
-	p.lastScavengeBackground = curScavengeBackground
-
-	return []float64{
-		scavengeAssist,
-		scavengeBackground,
-	}
-}
-
-// cpu overall
-
-type cpuOverall struct {
-	idxUser     int
-	idxScavenge int
-	idxIdle     int
-	idxGCtotal  int
-	idxTotal    int
-
-	lastTime     time.Time
-	lastUser     float64
-	lastScavenge float64
-	lastIdle     float64
-	lastGCtotal  float64
-	lastTotal    float64
-}
-
-func makeCPUoverall(indices ...int) metricsGetter {
-	return &cpuOverall{
-		idxUser:     indices[0],
-		idxScavenge: indices[1],
-		idxIdle:     indices[2],
-		idxGCtotal:  indices[3],
-		idxTotal:    indices[4],
-	}
-}
-
-func (p *cpuOverall) values(samples []metrics.Sample) any {
-	curUser := samples[p.idxUser].Value.Float64()
-	curScavenge := samples[p.idxScavenge].Value.Float64()
-	curIdle := samples[p.idxIdle].Value.Float64()
-	curGCtotal := samples[p.idxGCtotal].Value.Float64()
-	curTotal := samples[p.idxTotal].Value.Float64()
-
-	if p.lastTime.IsZero() {
-		p.lastUser = curUser
-		p.lastScavenge = curScavenge
-		p.lastIdle = curIdle
-		p.lastGCtotal = curGCtotal
-		p.lastTotal = curTotal
-
-		p.lastTime = time.Now()
-		return []float64{0, 0, 0, 0, 0}
-	}
-
-	t := time.Since(p.lastTime).Seconds()
-
-	user := (curUser - p.lastUser) / t
-	scavenge := (curScavenge - p.lastScavenge) / t
-	idle := (curIdle - p.lastIdle) / t
-	gcTotal := (curGCtotal - p.lastGCtotal) / t
-	total := (curTotal - p.lastTotal) / t
-
-	p.lastUser = curUser
-	p.lastScavenge = curScavenge
-	p.lastIdle = curIdle
-	p.lastGCtotal = curGCtotal
-	p.lastTotal = curTotal
-
-	return []float64{
-		user,
-		scavenge,
-		idle,
-		gcTotal,
-		total,
-	}
-}
-
-// mutex wait
-
-type mutexWait struct {
-	idxMutexWait int
-
-	lastTime      time.Time
-	lastMutexWait float64
-}
-
-func makeMutexWait(indices ...int) metricsGetter {
-	return &mutexWait{
-		idxMutexWait: indices[0],
-	}
-}
-
-func (p *mutexWait) values(samples []metrics.Sample) any {
-	if p.lastTime.IsZero() {
-		p.lastTime = time.Now()
-		p.lastMutexWait = samples[p.idxMutexWait].Value.Float64()
-
-		return []float64{0}
-	}
-
-	t := time.Since(p.lastTime).Seconds()
-
-	mutexWait := (samples[p.idxMutexWait].Value.Float64() - p.lastMutexWait) / t
-
-	p.lastMutexWait = samples[p.idxMutexWait].Value.Float64()
-	p.lastTime = time.Now()
-
-	return []float64{
-		mutexWait,
-	}
-}
-
-// gc scan
-
-type gcScan struct {
-	idxGlobals int
-	idxHeap    int
-	idxStack   int
-}
-
-func makeGCScan(indices ...int) metricsGetter {
-	return &gcScan{
-		idxGlobals: indices[0],
-		idxHeap:    indices[1],
-		idxStack:   indices[2],
-	}
-}
-
-func (p *gcScan) values(samples []metrics.Sample) any {
-	globals := samples[p.idxGlobals].Value.Uint64()
-	heap := samples[p.idxHeap].Value.Uint64()
-	stack := samples[p.idxStack].Value.Uint64()
-	return []uint64{
-		globals,
-		heap,
-		stack,
-	}
-}
-
-// alloc/free rates
-type allocFreeRates struct {
-	idxallocs int
-	idxfrees  int
-
-	lasttime   time.Time
-	lastallocs uint64
-	lastfrees  uint64
-}
-
-func makeAllocFreeRates(indices ...int) metricsGetter {
-	return &allocFreeRates{
-		idxallocs: indices[0],
-		idxfrees:  indices[1],
-	}
-}
-
-func (p *allocFreeRates) values(samples []metrics.Sample) any {
-	if p.lasttime.IsZero() {
-		p.lasttime = time.Now()
-		p.lastallocs = samples[p.idxallocs].Value.Uint64()
-		p.lastfrees = samples[p.idxfrees].Value.Uint64()
-
-		return []float64{0, 0}
-	}
-
-	t := time.Since(p.lasttime).Seconds()
-
-	allocs := float64(samples[p.idxallocs].Value.Uint64()-p.lastallocs) / t
-	frees := float64(samples[p.idxfrees].Value.Uint64()-p.lastfrees) / t
-
-	p.lastallocs = samples[p.idxallocs].Value.Uint64()
-	p.lastfrees = samples[p.idxfrees].Value.Uint64()
-	p.lasttime = time.Now()
-
-	return []float64{
-		allocs,
-		frees,
-	}
-}
-
-// stopping pauses (GC)
-
-type stoppingPausesGC struct {
-	histfactor int
-	counts     [maxBuckets]uint64
-
-	idxstoppinggc int
-}
-
-func makeGCStoppingPauses(indices ...int) metricsGetter {
-	return &stoppingPausesGC{
-		idxstoppinggc: indices[0],
-	}
-}
-
-func (p *stoppingPausesGC) values(samples []metrics.Sample) any {
-	if p.histfactor == 0 {
-		stoppinggc := samples[p.idxstoppinggc].Value.Float64Histogram()
-		p.histfactor = downsampleFactor(len(stoppinggc.Buckets), maxBuckets)
-	}
-
-	stoppinggc := samples[p.idxstoppinggc].Value.Float64Histogram()
-	return downsampleCounts(stoppinggc, p.histfactor, p.counts[:])
-}
-
-// stopping pauses (Other)
-
-type stoppingPausesOther struct {
-	histfactor int
-	counts     [maxBuckets]uint64
-
-	idxstoppingother int
-}
-
-func makeGCStoppingOther(indices ...int) metricsGetter {
-	return &stoppingPausesOther{
-		idxstoppingother: indices[0],
-	}
-}
-
-func (p *stoppingPausesOther) values(samples []metrics.Sample) any {
-	if p.histfactor == 0 {
-		stoppingother := samples[p.idxstoppingother].Value.Float64Histogram()
-		p.histfactor = downsampleFactor(len(stoppingother.Buckets), maxBuckets)
-	}
-
-	stoppingother := samples[p.idxstoppingother].Value.Float64Histogram()
-	return downsampleCounts(stoppingother, p.histfactor, p.counts[:])
-}
-
-// total pauses (Other)
-
-type totalPausesOther struct {
-	histfactor int
-	counts     [maxBuckets]uint64
-
-	idxtotalother int
-}
-
-func makeOtherTotalPauses(indices ...int) metricsGetter {
-	return &totalPausesOther{
-		idxtotalother: indices[0],
-	}
-}
-
-func (p *totalPausesOther) values(samples []metrics.Sample) any {
-	if p.histfactor == 0 {
-		totalother := samples[p.idxtotalother].Value.Float64Histogram()
-		p.histfactor = downsampleFactor(len(totalother.Buckets), maxBuckets)
-	}
-
-	totalother := samples[p.idxtotalother].Value.Float64Histogram()
-	return downsampleCounts(totalother, p.histfactor, p.counts[:])
-}
diff --git a/internal/plot/registry.go b/internal/plot/registry.go
index 7b5afa0..29ba1a6 100644
--- a/internal/plot/registry.go
+++ b/internal/plot/registry.go
@@ -1,203 +1,98 @@
+// Package plot defines and builds the plots available in Statsviz.
 package plot
 
 import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"runtime/debug"
 	"runtime/metrics"
 	"slices"
 	"sync"
-	"time"
 )
 
-// IsReservedPlotName reports whether that name is reserved for Statsviz plots
-// and thus can't be chosen by user (for user plots).
-func IsReservedPlotName(name string) bool {
-	if name == "timestamp" || name == "lastgc" {
-		return true
-	}
-	return slices.ContainsFunc(plotDescs, func(pd plotDesc) bool {
-		return pd.name == name
-	})
-}
-
-// a metricsGetter extracts, from a sample of runtime metrics, a slice with all
-// the metrics necessary for a single plot.
-type metricsGetter interface {
-	values([]metrics.Sample) any // []uint64 | []float64
-}
-
-// List holds all the plots that statsviz knows about. Some plots might be
-// disabled, if they rely on metrics that are unknown to the current Go version.
-type List struct {
-	rtPlots   []runtimePlot
-	userPlots []UserPlot
+type tag = string
 
-	once sync.Once // ensure Config is built once
-	cfg  *Config
+const (
+	tagGC        tag = "gc"
+	tagScheduler tag = "scheduler"
+	tagCPU       tag = "cpu"
+	tagMisc      tag = "misc"
+)
 
-	idxs        map[string]int // map metrics name to idx in samples and descs
-	descs       []metrics.Description
-	usedMetrics map[string]struct{}
+type description struct {
+	metrics []string
+	layout  any
 
-	mu      sync.Mutex // protects samples in case of concurrent calls to WriteValues
-	samples []metrics.Sample
+	// getvalues creates the state (support struct) for the plot.
+	getvalues func() getvalues
 }
 
-type runtimePlot struct {
-	name   string
-	rt     metricsGetter
-	layout any // Scatter | Heatmap
-}
+type registry struct {
+	allMetrics   map[string]bool // names of all known runtime/metrics metrics
+	metrics      []string
+	descriptions []description
 
-func NewList(userPlots []UserPlot) (*List, error) {
-	if name := hasDuplicatePlotNames(userPlots); name != "" {
-		return nil, fmt.Errorf("duplicate plot name %s", name)
-	}
+	samples []metrics.Sample // lazily built, only with the metrics we need
+}
 
-	descs := metrics.All()
-	pl := &List{
-		idxs:        make(map[string]int),
-		descs:       descs,
-		samples:     make([]metrics.Sample, len(descs)),
-		userPlots:   userPlots,
-		usedMetrics: make(map[string]struct{}),
+var reg = sync.OnceValue(func() *registry {
+	reg := &registry{
+		allMetrics: make(map[string]bool),
 	}
-	for i := range pl.samples {
-		pl.samples[i].Name = pl.descs[i].Name
+	for _, m := range metrics.All() {
+		reg.allMetrics[m.Name] = true
 	}
-	metrics.Read(pl.samples)
 
-	return pl, nil
-}
+	return reg
+})
 
-func (pl *List) enabledPlots() []runtimePlot {
-	plots := make([]runtimePlot, 0, len(plotDescs))
-
-	for _, plot := range plotDescs {
-		indices, enabled := pl.indicesFor(plot.metrics...)
-		if enabled {
-			plots = append(plots, runtimePlot{
-				name:   plot.name,
-				rt:     plot.make(indices...),
-				layout: complete(plot.layout, plot.name, plot.tags),
-			})
-		}
+func (r *registry) mustidx(metric string) int {
+	if !r.allMetrics[metric] {
+		panic(metric + ": unknown metric in " + goversion())
 	}
 
-	return plots
-}
-
-// complete the layout with names and tags.
-func complete(layout any, name string, tags []string) any {
-	switch layout := layout.(type) {
-	case Scatter:
-		layout.Name = name
-		layout.Tags = tags
-		return layout
-	case Heatmap:
-		layout.Name = name
-		layout.Tags = tags
-		return layout
-	default:
-		panic(fmt.Sprintf("unknown plot layout type %T", layout))
+	idx := slices.Index(r.metrics, metric)
+	if idx == -1 {
+		r.metrics = append(r.metrics, metric)
+		idx = len(r.metrics) - 1
 	}
-}
 
-func (pl *List) Config() *Config {
-	pl.once.Do(func() {
-		pl.rtPlots = pl.enabledPlots()
-
-		layouts := make([]any, len(pl.rtPlots))
-		for i := range pl.rtPlots {
-			layouts[i] = pl.rtPlots[i].layout
-		}
-
-		pl.cfg = &Config{
-			Events: []string{"lastgc"},
-			Series: layouts,
-		}
-
-		// User plots go at the back.
-		for i := range pl.userPlots {
-			pl.cfg.Series = append(pl.cfg.Series, pl.userPlots[i].Layout())
-		}
-	})
-	return pl.cfg
+	return idx
 }
 
-// WriteValues writes into w a JSON object containing the data points for all
-// plots at the current instant.
-func (pl *List) WriteValues(w io.Writer) error {
-	pl.mu.Lock()
-	defer pl.mu.Unlock()
-
-	metrics.Read(pl.samples)
-
-	// lastgc time series is used as source to represent garbage collection
-	// timestamps as vertical bars on certain plots.
-	gcStats := debug.GCStats{}
-	debug.ReadGCStats(&gcStats)
-
-	m := map[string]any{
-		// Javascript timestamps are in millis.
-		"lastgc": []int64{gcStats.LastGC.UnixMilli()},
+func (r *registry) buildSamples() {
+	r.samples = make([]metrics.Sample, len(r.metrics))
+	for i := range r.samples {
+		r.samples[i].Name = r.metrics[i]
 	}
+}
 
-	for _, p := range pl.rtPlots {
-		m[p.name] = p.rt.values(pl.samples)
+func (r *registry) read() []metrics.Sample {
+	if r.samples == nil {
+		r.buildSamples()
 	}
+	metrics.Read(r.samples)
 
-	for i := range pl.userPlots {
-		up := &pl.userPlots[i]
-		switch {
-		case up.Scatter != nil:
-			vals := make([]float64, len(up.Scatter.Funcs))
-			for i := range up.Scatter.Funcs {
-				vals[i] = up.Scatter.Funcs[i]()
-			}
-			m[up.Scatter.Plot.Name] = vals
-		case up.Heatmap != nil:
-			panic("unimplemented")
-		}
-	}
+	return r.samples
+}
 
-	type data struct {
-		Series    map[string]any `json:"series"`
-		Timestamp int64          `json:"timestamp"`
+func (r *registry) register(desc description) {
+	// Histograms need special handling.
+	type heatmapLayoutFunc = func(samples []metrics.Sample) Heatmap
+	if buildLayout, ok := desc.layout.(heatmapLayoutFunc); ok {
+		// Rebuild samples to include the required metrics.
+		r.buildSamples()
+		samples := r.read()
+		desc.layout = buildLayout(samples)
 	}
 
-	if err := json.NewEncoder(w).Encode(struct {
-		Event string `json:"event"`
-		Data  data   `json:"data"`
-	}{
-		Event: "metrics",
-		Data: data{
-			Series:    m,
-			Timestamp: time.Now().UnixMilli(),
-		},
-	}); err != nil {
-		return fmt.Errorf("failed to write/convert metrics values to json: %v", err)
-	}
-	return nil
+	r.descriptions = append(r.descriptions, desc)
 }
 
-// indicesFor retrieves indices for the specified metrics, and a boolean
-// indicating whether they were all found.
-func (pl *List) indicesFor(metricNames ...string) ([]int, bool) {
-	indices := make([]int, len(metricNames))
-	allFound := true
-
-	for i, name := range metricNames {
-		pl.usedMetrics[name] = struct{}{} // record the metrics we use
-
-		idx, ok := metricIdx[name]
-		if !ok {
-			allFound = false
-		}
-		indices[i] = idx
-	}
+func mustidx(metric string) int {
+	// TODO: adapter for refactoring: remove
+	return reg().mustidx(metric)
+}
 
-	return indices, allFound
+func register(desc description) struct{} {
+	// TODO: adapter for refactoring: remove
+	reg().register(desc)
+	return struct{}{}
 }
diff --git a/statsviz.go b/statsviz.go
index 62c7ecb..77718c2 100644
--- a/statsviz.go
+++ b/statsviz.go
@@ -41,6 +41,8 @@
 package statsviz
 
 import (
+	"bytes"
+	"context"
 	"fmt"
 	"net/http"
 	"os"
@@ -68,6 +70,8 @@ func RegisterDefault(opts ...Option) error {
 }
 
 // Register registers the Statsviz HTTP handlers on the provided mux.
+//
+// Register must be called once per application.
 func Register(mux *http.ServeMux, opts ...Option) error {
 	srv, err := NewServer(opts...)
 	if err != nil {
@@ -85,7 +89,13 @@ func Register(mux *http.ServeMux, opts ...Option) error {
 //     browser to receive metrics updates from the server.
 //
 // The zero value is a valid Server, with default options.
+//
+// NOTE: Having more than one Server in the same program is not supported (and
+// is not useful anyway).
 type Server struct {
+	cancel  context.CancelFunc // terminate goroutines
+	clients *clients           // connected websocket clients
+
 	interval  time.Duration // interval between consecutive metrics emission
 	root      string        // HTTP path root
 	plots     *plot.List    // plots shown on the user interface
@@ -124,18 +134,52 @@ func (s *Server) init(opts ...Option) error {
 	}
 	s.plots = pl
 
+	ctx, cancel := context.WithCancel(context.Background())
+	s.cancel = cancel
+	s.clients = newClients(ctx, s.plots.Config())
+
+	// Collect metrics.
+	go func() {
+		tick := time.NewTicker(s.interval)
+		defer tick.Stop()
+		defer cancel()
+
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-tick.C:
+				buf := bytes.Buffer{}
+				if _, err := s.plots.WriteTo(&buf); err != nil {
+					dbglog("failed to collect metrics: %v", err)
+					return
+				}
+				s.clients.broadcast(buf.Bytes())
+			}
+		}
+	}()
+
 	return nil
 }
 
 // Register registers the Statsviz HTTP handlers on the provided mux.
+//
+// Register must be called once per application.
 func (s *Server) Register(mux *http.ServeMux) {
 	if s.plots == nil {
 		s.init()
 	}
+
 	mux.Handle(s.root+"/", s.Index())
 	mux.HandleFunc(s.root+"/ws", s.Ws())
 }
 
+// Close releases all resources used by the Server.
+func (s *Server) Close() error {
+	s.cancel()
+	return nil
+}
+
 // Option is a configuration option for the Server.
 type Option func(*Server) error
 
@@ -155,7 +199,7 @@ func SendFrequency(intv time.Duration) Option {
 // The default is "/debug/statsviz".
 func Root(path string) Option {
 	return func(s *Server) error {
-		s.root = path
+		s.root = strings.TrimSuffix(path, "/")
 		return nil
 	}
 }
@@ -173,7 +217,7 @@ func TimeseriesPlot(tsp TimeSeriesPlot) Option {
 // interface HTML page. By default, the handler is served at the path specified
 // by the root. Use [WithRoot] to change the path.
 func (s *Server) Index() http.HandlerFunc {
-	prefix := strings.TrimSuffix(s.root, "/") + "/"
+	prefix := s.root + "/"
 	dist := http.FileServerFS(static.Assets())
 	return http.StripPrefix(prefix, dist).ServeHTTP
 }
@@ -191,6 +235,12 @@ func parseBoolEnv(name string) bool {
 
 var debug = false
 
+func dbglog(format string, args ...any) {
+	if debug {
+		fmt.Fprintf(os.Stderr, "statsviz: "+format+"\n", args...)
+	}
+}
+
 var wsUpgrader = sync.OnceValue(func() websocket.Upgrader {
 	var checkOrigin func(r *http.Request) bool
 
@@ -215,63 +265,10 @@ func (s *Server) Ws() http.HandlerFunc {
 		upgrader := wsUpgrader()
 		ws, err := upgrader.Upgrade(w, r, nil)
 		if err != nil {
-			if debug {
-				fmt.Fprintf(os.Stderr, "statsviz: failed to upgrade connection: %v\n", err)
-			}
+			dbglog("failed to upgrade connection: %v", err)
 			return
 		}
 
-		defer ws.Close()
-
-		// Ignore websocket errors here. They mainly happen when the other end
-		// of the connection closes. We can't handle them in any meaningful way
-		// anyways, and since we're a library we don't want to spam the program
-		// output streams. If really necessary, we could export a io.Writer the
-		// user could connect to whatever logging facility they use, that seems
-		// overkill for now.
-
-		if err := s.sendConfig(ws); err != nil {
-			if debug {
-				fmt.Fprintf(os.Stderr, "statsviz: failed to send config: %v\n", err)
-			}
-		}
-		if err := s.sendStats(ws, s.interval); err != nil {
-			if debug {
-				fmt.Fprintf(os.Stderr, "statsviz: failed to send stats: %v\n", err)
-			}
-		}
-	}
-}
-
-type wsmsg struct {
-	Event string `json:"event"`
-	Data  any    `json:"data"`
-}
-
-func (s *Server) sendConfig(conn *websocket.Conn) error {
-	return conn.WriteJSON(wsmsg{
-		Event: "config",
-		Data:  s.plots.Config(),
-	})
-}
-
-// sendStats sends runtime statistics over the WebSocket connection.
-func (s *Server) sendStats(conn *websocket.Conn, frequency time.Duration) error {
-	tick := time.NewTicker(frequency)
-	defer tick.Stop()
-
-	for range tick.C {
-		w, err := conn.NextWriter(websocket.TextMessage)
-		if err != nil {
-			return err
-		}
-		if err := s.plots.WriteValues(w); err != nil {
-			return err
-		}
-		if err := w.Close(); err != nil {
-			return err
-		}
+		s.clients.add(ws)
 	}
-
-	panic("unreachable")
 }



Test patch:
diff --git a/internal/plot/plots_test.go b/internal/plot/plots_test.go
index 091c161..4c20ca5 100644
--- a/internal/plot/plots_test.go
+++ b/internal/plot/plots_test.go
@@ -4,11 +4,45 @@ import (
 	"fmt"
 	"os"
 	"runtime/metrics"
+	"slices"
 	"strings"
 	"testing"
 	"text/tabwriter"
 )
 
+func TestUnusedRuntimeMetrics(t *testing.T) {
+	// This test just prints the metrics we're not using in any plot. It can't
+	// fail, it's informational.
+	used := make(map[string]bool)
+	for _, d := range reg().descriptions {
+		for _, m := range d.metrics {
+			used[m] = true
+		}
+	}
+
+	// Discard godebug metrics and used metrics.
+	all := metrics.All()
+	all = slices.DeleteFunc(all, func(desc metrics.Description) bool {
+		return strings.HasPrefix(desc.Name, "/godebug/")
+	})
+	all = slices.DeleteFunc(all, func(desc metrics.Description) bool {
+		return used[desc.Name]
+	})
+
+	if len(all) == 0 {
+		t.Log("all metrics are used!")
+		return
+	}
+
+	t.Log("some runtime metrics are not used by any plot:\n")
+
+	w := tabwriter.NewWriter(os.Stderr, 0, 8, 2, ' ', 0)
+	for _, m := range all {
+		fmt.Fprintf(w, "\t%s\t%s\t%s\n", m.Name, kindstr(m.Kind), clampstr(m.Description))
+	}
+	w.Flush()
+}
+
 func kindstr(k metrics.ValueKind) string {
 	switch k {
 	case metrics.KindUint64:
@@ -22,23 +56,10 @@ func kindstr(k metrics.ValueKind) string {
 	}
 }
 
-func TestUnusedRuntimeMetrics(t *testing.T) {
-	// Creating a config compiles the list of metrics used by Statsviz.
-	l, err := NewList(nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	_ = l.Config()
-
-	// This "test" can't fail. It just prints which of the metrics exported by
-	// runtime/metrics are not used in any Statsviz plot.
-	w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
-	for _, m := range metrics.All() {
-		if _, ok := l.usedMetrics[m.Name]; !ok &&
-			!strings.HasPrefix(m.Name, "/godebug/") &&
-			m.Name != "/gc/pauses:seconds" /* deprecated name */ {
-			fmt.Fprintf(w, "%s\t%s\n", m.Name, kindstr(m.Kind))
-		}
+func clampstr(s string) string {
+	const maxlen = 80
+	if len(s) > maxlen {
+		return s[:maxlen-3] + "..."
 	}
-	w.Flush()
+	return s
 }
diff --git a/netconn_test.go b/netconn_test.go
new file mode 100644
index 0000000..cbe28d8
--- /dev/null
+++ b/netconn_test.go
@@ -0,0 +1,442 @@
+//go:build go1.25
+// +build go1.25
+
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package statsviz
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"math"
+	"net"
+	"net/netip"
+	"os"
+	"sync"
+	"testing/synctest"
+	"time"
+)
+
+func fakeNetListen() *fakeNetListener {
+	li := &fakeNetListener{
+		setc:    make(chan struct{}, 1),
+		unsetc:  make(chan struct{}, 1),
+		addr:    netip.MustParseAddrPort("127.0.0.1:8000"),
+		locPort: 10000,
+	}
+	li.unsetc <- struct{}{}
+	return li
+}
+
+type fakeNetListener struct {
+	setc, unsetc chan struct{}
+	queue        []net.Conn
+	closed       bool
+	addr         netip.AddrPort
+	locPort      uint16
+
+	onDial  func()             // called when making a new connection
+	onClose func(*fakeNetConn) // called when closing a connection
+
+	trackConns bool // set this to record all created conns
+	conns      []*fakeNetConn
+}
+
+func (li *fakeNetListener) lock() {
+	select {
+	case <-li.setc:
+	case <-li.unsetc:
+	}
+}
+
+func (li *fakeNetListener) unlock() {
+	if li.closed || len(li.queue) > 0 {
+		li.setc <- struct{}{}
+	} else {
+		li.unsetc <- struct{}{}
+	}
+}
+
+func (li *fakeNetListener) connect() *fakeNetConn {
+	if li.onDial != nil {
+		li.onDial()
+	}
+	li.lock()
+	defer li.unlock()
+	locAddr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), li.locPort)
+	li.locPort++
+	c0, c1 := fakeNetPipe(li.addr, locAddr)
+	c0.onClose = li.onClose
+	c1.onClose = li.onClose
+	li.queue = append(li.queue, c0)
+	if li.trackConns {
+		li.conns = append(li.conns, c0)
+	}
+	return c1
+}
+
+func (li *fakeNetListener) Accept() (net.Conn, error) {
+	<-li.setc
+	defer li.unlock()
+	if li.closed {
+		return nil, net.ErrClosed
+	}
+	c := li.queue[0]
+	li.queue = li.queue[1:]
+	return c, nil
+}
+
+func (li *fakeNetListener) Close() error {
+	li.lock()
+	defer li.unlock()
+	li.closed = true
+	return nil
+}
+
+func (li *fakeNetListener) Addr() net.Addr {
+	return net.TCPAddrFromAddrPort(li.addr)
+}
+
+// fakeNetPipe creates an in-memory, full duplex network connection.
+//
+// Unlike net.Pipe, the connection is not synchronous.
+// Writes are made to a buffer, and return immediately.
+// By default, the buffer size is unlimited.
+func fakeNetPipe(s1ap, s2ap netip.AddrPort) (r, w *fakeNetConn) {
+	s1addr := net.TCPAddrFromAddrPort(s1ap)
+	s2addr := net.TCPAddrFromAddrPort(s2ap)
+	s1 := newSynctestNetConnHalf(s1addr)
+	s2 := newSynctestNetConnHalf(s2addr)
+	c1 := &fakeNetConn{loc: s1, rem: s2}
+	c2 := &fakeNetConn{loc: s2, rem: s1}
+	c1.peer = c2
+	c2.peer = c1
+	return c1, c2
+}
+
+// A fakeNetConn is one endpoint of the connection created by fakeNetPipe.
+type fakeNetConn struct {
+	// local and remote connection halves.
+	// Each half contains a buffer.
+	// Reads pull from the local buffer, and writes push to the remote buffer.
+	loc, rem *fakeNetConnHalf
+
+	// When set, synctest.Wait is automatically called before reads and after writes.
+	autoWait bool
+
+	// peer is the other endpoint.
+	peer *fakeNetConn
+
+	onClose func(*fakeNetConn) // called when closing
+}
+
+// Read reads data from the connection.
+func (c *fakeNetConn) Read(b []byte) (n int, err error) {
+	if c.autoWait {
+		synctest.Wait()
+	}
+	return c.loc.read(b)
+}
+
+// Peek returns the available unread read buffer,
+// without consuming its contents.
+func (c *fakeNetConn) Peek() []byte {
+	if c.autoWait {
+		synctest.Wait()
+	}
+	return c.loc.peek()
+}
+
+// Write writes data to the connection.
+func (c *fakeNetConn) Write(b []byte) (n int, err error) {
+	if c.autoWait {
+		defer synctest.Wait()
+	}
+	return c.rem.write(b)
+}
+
+// IsClosed reports whether the peer has closed its end of the connection.
+func (c *fakeNetConn) IsClosedByPeer() bool {
+	if c.autoWait {
+		synctest.Wait()
+	}
+	c.rem.lock()
+	defer c.rem.unlock()
+	// If the remote half of the conn is returning ErrClosed,
+	// the peer has closed the connection.
+	return c.rem.readErr == net.ErrClosed
+}
+
+// Close closes the connection.
+func (c *fakeNetConn) Close() error {
+	if c.onClose != nil {
+		c.onClose(c)
+	}
+	// Local half of the conn is now closed.
+	c.loc.lock()
+	c.loc.writeErr = net.ErrClosed
+	c.loc.readErr = net.ErrClosed
+	c.loc.buf.Reset()
+	c.loc.unlock()
+	// Remote half of the connection reads EOF after reading any remaining data.
+	c.rem.lock()
+	if c.rem.readErr != nil {
+		c.rem.readErr = io.EOF
+	}
+	c.rem.unlock()
+	if c.autoWait {
+		synctest.Wait()
+	}
+	return nil
+}
+
+// LocalAddr returns the (fake) local network address.
+func (c *fakeNetConn) LocalAddr() net.Addr {
+	return c.loc.addr
+}
+
+// LocalAddr returns the (fake) remote network address.
+func (c *fakeNetConn) RemoteAddr() net.Addr {
+	return c.rem.addr
+}
+
+// SetDeadline sets the read and write deadlines for the connection.
+func (c *fakeNetConn) SetDeadline(t time.Time) error {
+	c.SetReadDeadline(t)
+	c.SetWriteDeadline(t)
+	return nil
+}
+
+// SetReadDeadline sets the read deadline for the connection.
+func (c *fakeNetConn) SetReadDeadline(t time.Time) error {
+	c.loc.rctx.setDeadline(t)
+	return nil
+}
+
+// SetWriteDeadline sets the write deadline for the connection.
+func (c *fakeNetConn) SetWriteDeadline(t time.Time) error {
+	c.rem.wctx.setDeadline(t)
+	return nil
+}
+
+// SetReadBufferSize sets the read buffer limit for the connection.
+// Writes by the peer will block so long as the buffer is full.
+func (c *fakeNetConn) SetReadBufferSize(size int) {
+	c.loc.setReadBufferSize(size)
+}
+
+// fakeNetConnHalf is one data flow in the connection created by fakeNetPipe.
+// Each half contains a buffer. Writes to the half push to the buffer, and reads pull from it.
+type fakeNetConnHalf struct {
+	addr net.Addr
+
+	// Read and write timeouts.
+	rctx, wctx deadlineContext
+
+	// A half can be readable and/or writable.
+	//
+	// These four channels act as a lock,
+	// and allow waiting for readability/writability.
+	// When the half is unlocked, exactly one channel contains a value.
+	// When the half is locked, all channels are empty.
+	lockr  chan struct{} // readable
+	lockw  chan struct{} // writable
+	lockrw chan struct{} // readable and writable
+	lockc  chan struct{} // neither readable nor writable
+
+	bufMax   int // maximum buffer size
+	buf      bytes.Buffer
+	readErr  error // error returned by reads
+	writeErr error // error returned by writes
+}
+
+func newSynctestNetConnHalf(addr net.Addr) *fakeNetConnHalf {
+	h := &fakeNetConnHalf{
+		addr:   addr,
+		lockw:  make(chan struct{}, 1),
+		lockr:  make(chan struct{}, 1),
+		lockrw: make(chan struct{}, 1),
+		lockc:  make(chan struct{}, 1),
+		bufMax: math.MaxInt, // unlimited
+	}
+	h.unlock()
+	return h
+}
+
+// lock locks h.
+func (h *fakeNetConnHalf) lock() {
+	select {
+	case <-h.lockw: // writable
+	case <-h.lockr: // readable
+	case <-h.lockrw: // readable and writable
+	case <-h.lockc: // neither readable nor writable
+	}
+}
+
+// h unlocks h.
+func (h *fakeNetConnHalf) unlock() {
+	canRead := h.readErr != nil || h.buf.Len() > 0
+	canWrite := h.writeErr != nil || h.bufMax > h.buf.Len()
+	switch {
+	case canRead && canWrite:
+		h.lockrw <- struct{}{} // readable and writable
+	case canRead:
+		h.lockr <- struct{}{} // readable
+	case canWrite:
+		h.lockw <- struct{}{} // writable
+	default:
+		h.lockc <- struct{}{} // neither readable nor writable
+	}
+}
+
+// waitAndLockForRead waits until h is readable and locks it.
+func (h *fakeNetConnHalf) waitAndLockForRead() error {
+	// First a non-blocking select to see if we can make immediate progress.
+	// This permits using a canceled context for a non-blocking operation.
+	select {
+	case <-h.lockr:
+		return nil // readable
+	case <-h.lockrw:
+		return nil // readable and writable
+	default:
+	}
+	ctx := h.rctx.context()
+	select {
+	case <-h.lockr:
+		return nil // readable
+	case <-h.lockrw:
+		return nil // readable and writable
+	case <-ctx.Done():
+		return context.Cause(ctx)
+	}
+}
+
+// waitAndLockForWrite waits until h is writable and locks it.
+func (h *fakeNetConnHalf) waitAndLockForWrite() error {
+	// First a non-blocking select to see if we can make immediate progress.
+	// This permits using a canceled context for a non-blocking operation.
+	select {
+	case <-h.lockw:
+		return nil // writable
+	case <-h.lockrw:
+		return nil // readable and writable
+	default:
+	}
+	ctx := h.wctx.context()
+	select {
+	case <-h.lockw:
+		return nil // writable
+	case <-h.lockrw:
+		return nil // readable and writable
+	case <-ctx.Done():
+		return context.Cause(ctx)
+	}
+}
+
+func (h *fakeNetConnHalf) peek() []byte {
+	h.lock()
+	defer h.unlock()
+	return h.buf.Bytes()
+}
+
+func (h *fakeNetConnHalf) read(b []byte) (n int, err error) {
+	if err := h.waitAndLockForRead(); err != nil {
+		return 0, err
+	}
+	defer h.unlock()
+	if h.buf.Len() == 0 && h.readErr != nil {
+		return 0, h.readErr
+	}
+	return h.buf.Read(b)
+}
+
+func (h *fakeNetConnHalf) setReadBufferSize(size int) {
+	h.lock()
+	defer h.unlock()
+	h.bufMax = size
+}
+
+func (h *fakeNetConnHalf) write(b []byte) (n int, err error) {
+	for n < len(b) {
+		nn, err := h.writePartial(b[n:])
+		n += nn
+		if err != nil {
+			return n, err
+		}
+	}
+	return n, nil
+}
+
+func (h *fakeNetConnHalf) writePartial(b []byte) (n int, err error) {
+	if err := h.waitAndLockForWrite(); err != nil {
+		return 0, err
+	}
+	defer h.unlock()
+	if h.writeErr != nil {
+		return 0, h.writeErr
+	}
+	writeMax := h.bufMax - h.buf.Len()
+	if writeMax < len(b) {
+		b = b[:writeMax]
+	}
+	return h.buf.Write(b)
+}
+
+// deadlineContext converts a changable deadline (as in net.Conn.SetDeadline) into a Context.
+type deadlineContext struct {
+	mu     sync.Mutex
+	ctx    context.Context
+	cancel context.CancelCauseFunc
+	timer  *time.Timer
+}
+
+// context returns a Context which expires when the deadline does.
+func (t *deadlineContext) context() context.Context {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	if t.ctx == nil {
+		t.ctx, t.cancel = context.WithCancelCause(context.Background())
+	}
+	return t.ctx
+}
+
+// setDeadline sets the current deadline.
+func (t *deadlineContext) setDeadline(deadline time.Time) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	// If t.ctx is non-nil and t.cancel is nil, then t.ctx was canceled
+	// and we should create a new one.
+	if t.ctx == nil || t.cancel == nil {
+		t.ctx, t.cancel = context.WithCancelCause(context.Background())
+	}
+	// Stop any existing deadline from expiring.
+	if t.timer != nil {
+		t.timer.Stop()
+	}
+	if deadline.IsZero() {
+		// No deadline.
+		return
+	}
+	now := time.Now()
+	if !deadline.After(now) {
+		// Deadline has already expired.
+		t.cancel(os.ErrDeadlineExceeded)
+		t.cancel = nil
+		return
+	}
+	if t.timer != nil {
+		// Reuse existing deadline timer.
+		t.timer.Reset(deadline.Sub(now))
+		return
+	}
+	// Create a new timer to cancel the context at the deadline.
+	t.timer = time.AfterFunc(deadline.Sub(now), func() {
+		t.mu.Lock()
+		defer t.mu.Unlock()
+		t.cancel(os.ErrDeadlineExceeded)
+		t.cancel = nil
+	})
+}
diff --git a/statsviz_test.go b/statsviz_test.go
index e3f8ea0..2ad98ad 100644
--- a/statsviz_test.go
+++ b/statsviz_test.go
@@ -103,14 +103,15 @@ func testWs(t *testing.T, f http.Handler, URL string) {
 	}
 
 	// Check the content of 2 consecutive payloads.
-	for i := 0; i < 2; i++ {
-		// Verifies that we've received 1 time series (goroutines) and one
-		// heatmap (sizeClasses).
+	for range 2 {
+		// Verifies that we've received:
+		// - 1 time series (cgo)
+		// - 1 heatmap (sizeClasses).
 		var msg struct {
 			Event string `json:"event"`
 			Data  struct {
 				Series struct {
-					Goroutines  []uint64 `json:"goroutines"`
+					CGo         []uint64 `json:"cgo"`
 					SizeClasses []uint64 `json:"size-classes"`
 				} `json:"series"`
 			} `json:"data"`
@@ -121,8 +122,8 @@ func testWs(t *testing.T, f http.Handler, URL string) {
 		}
 
 		// The time series must have one and only one element
-		if len(msg.Data.Series.Goroutines) != 1 {
-			t.Errorf("len(goroutines) = %d, want 1", len(msg.Data.Series.Goroutines))
+		if len(msg.Data.Series.CGo) != 1 {
+			t.Errorf("len(cgo) = %d, want 1", len(msg.Data.Series.CGo))
 		}
 		// Heatmaps should have many elements, check that there's more than one.
 		if len(msg.Data.Series.SizeClasses) <= 1 {
@@ -131,12 +132,6 @@ func testWs(t *testing.T, f http.Handler, URL string) {
 	}
 }
 
-func TestWs(t *testing.T) {
-	t.Parallel()
-
-	testWs(t, newServer(t).Ws(), "http://example.com/debug/statsviz/ws")
-}
-
 func TestWsCantUpgrade(t *testing.T) {
 	url := "http://example.com/debug/statsviz/ws"
 
@@ -156,10 +151,20 @@ func testRegister(t *testing.T, f http.Handler, baseURL string) {
 }
 
 func TestRegister(t *testing.T) {
+	t.Run("defaultmux", func(t *testing.T) {
+		t.Parallel()
+
+		mux := http.DefaultServeMux
+
+		Register(mux)
+		testRegister(t, mux, "http://example.com/debug/statsviz/")
+	})
+
 	t.Run("default", func(t *testing.T) {
 		t.Parallel()
 
 		mux := http.NewServeMux()
+
 		newServer(t).Register(mux)
 		testRegister(t, mux, "http://example.com/debug/statsviz/")
 	})
@@ -178,10 +183,19 @@ func TestRegister(t *testing.T) {
 		t.Parallel()
 
 		mux := http.NewServeMux()
-		newServer(t,
-			Root(""),
-		).Register(mux)
 
+		srv := newServer(t, Root(""))
+		srv.Register(mux)
+		testRegister(t, mux, "http://example.com/")
+	})
+
+	t.Run("slash", func(t *testing.T) {
+		t.Parallel()
+
+		mux := http.NewServeMux()
+
+		srv := newServer(t, Root("/"))
+		srv.Register(mux)
 		testRegister(t, mux, "http://example.com/")
 	})
 
@@ -189,10 +203,9 @@ func TestRegister(t *testing.T) {
 		t.Parallel()
 
 		mux := http.NewServeMux()
-		newServer(t,
-			Root("/path/to/statsviz"),
-		).Register(mux)
 
+		srv := newServer(t, Root("/path/to/statsviz"))
+		srv.Register(mux)
 		testRegister(t, mux, "http://example.com/path/to/statsviz/")
 	})
 
@@ -200,28 +213,24 @@ func TestRegister(t *testing.T) {
 		t.Parallel()
 
 		mux := http.NewServeMux()
-		newServer(t,
+
+		srv := newServer(t,
 			Root("/path/to/statsviz"),
 			SendFrequency(100*time.Millisecond),
-		).Register(mux)
-
+		)
+		srv.Register(mux)
 		testRegister(t, mux, "http://example.com/path/to/statsviz/")
 	})
 
 	t.Run("non-positive frequency", func(t *testing.T) {
 		t.Parallel()
 
-		if _, err := NewServer(
+		_, err := NewServer(
 			Root("/path/to/statsviz"),
 			SendFrequency(-1),
-		); err == nil {
+		)
+		if err == nil {
 			t.Errorf("NewServer() should have errored")
 		}
 	})
 }
-
-func TestRegisterDefault(t *testing.T) {
-	mux := http.DefaultServeMux
-	Register(mux)
-	testRegister(t, mux, "http://example.com/debug/statsviz/")
-}
diff --git a/sync_test.go b/sync_test.go
new file mode 100644
index 0000000..f152c4c
--- /dev/null
+++ b/sync_test.go
@@ -0,0 +1,91 @@
+//go:build go1.25
+// +build go1.25
+
+package statsviz
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+	"testing/synctest"
+
+	"github.com/gorilla/websocket"
+)
+
+func TestWsConcurrent(t *testing.T) {
+	t.Parallel()
+
+	synctest.Test(t, func(t *testing.T) {
+		srv := newServer(t)
+		srv.Register(http.NewServeMux())
+
+		li := fakeNetListen()
+		s := &httptest.Server{
+			Listener: li,
+			Config:   &http.Server{Handler: srv.Ws()},
+		}
+		s.Start()
+
+		// Build a "ws://" url using the httptest server URL.
+		u, err := url.Parse(s.URL)
+		if err != nil {
+			t.Fatal(err)
+		}
+		u.Scheme = "ws"
+
+		const numConns = 10
+		const numMessages = 200
+
+		errCh := make(chan error, numConns)
+
+		synctestWsDialer := websocket.Dialer{
+			NetDialContext: func(ctx context.Context, network string, addr string) (net.Conn, error) {
+				c := li.connect()
+				return c, nil
+			},
+		}
+
+		for i := range numConns {
+			go func(connID int) {
+				ws, _, err := synctestWsDialer.Dial(u.String(), nil)
+				if err != nil {
+					errCh <- err
+					return
+				}
+				defer ws.Close()
+
+				// First message is the plots configuration
+				var cfg map[string]any
+				if err := ws.ReadJSON(&cfg); err != nil {
+					errCh <- err
+					return
+				}
+
+				// Read multiple data messages to ensure state is being accessed
+				for range numMessages {
+					var msg map[string]any
+					if err := ws.ReadJSON(&msg); err != nil {
+						errCh <- err
+						return
+					}
+				}
+
+				errCh <- nil
+			}(i)
+		}
+
+		for i := range numConns {
+			if err := <-errCh; err != nil {
+				t.Fatalf("connection %d failed: %v", i, err)
+			}
+		}
+
+		srv.Close()
+		s.Close()
+
+		synctest.Wait()
+	})
+}