#1226·rod

fix: Page.cleanupStates() does not remove per-session CDP states, causing unbounded memory growth

Author: VongoCreated Feb 23, 2026Updated Sep 8, 2026

Rod Version: v0.116.2

Description

Page.cleanupStates() only removes the cached *Page entry (keyed by TargetID) from browser.states, but does not remove the per-session CDP method states that were accumulated by Browser.set() during the page's lifetime.

Every Browser.Call() invocation stores its params in browser.states (a sync.Map) keyed by stateKey{browserContextID, sessionID, methodName}. When a page is closed, these entries are never deleted. For long-lived browsers with many page open/close cycles, this is an unbounded memory leak.

Root Cause

Browser.Call()Browser.set() stores params forever

go
// browser.go
func (b *Browser) Call(ctx context.Context, sessionID, methodName string, params interface{}) (res []byte, err error) {
    res, err = b.client.Call(ctx, sessionID, methodName, params)
    if err != nil {
        return nil, err
    }
    b.set(proto.TargetSessionID(sessionID), methodName, params)  // ← stores params permanently
    return
}

Page.cleanupStates() only removes the TargetID entry

go
// states.go
func (p *Page) cleanupStates() {
    p.browser.RemoveState(p.TargetID)  // Only removes the cached *Page entry
}

This removes the TargetID → *Page entry stored by cachePage(), but none of the stateKey → params entries stored by set().

Why it leaks

Each page has a unique SessionID. Over the browser's lifetime:

  1. Page is created → gets a SessionID
  2. CDP calls are made → stateKey{..., sessionID, method} entries accumulate in browser.states
  3. Page is closed → cleanupStates() only removes the TargetID entry
  4. The stateKey entries remain forever because no code iterates the map to find entries matching the closed page's SessionID

Impact

  • Any CDP call with large params (e.g., Page.SetDocumentContent with multi-MB HTML) permanently leaks the params struct
  • Affects any application using a long-lived *rod.Browser with many page open/close cycles
  • Severity scales with the number and size of CDP calls made per page

Code to Reproduce

go
package main

import (
	"fmt"
	"runtime"
	"strings"

	"github.com/go-rod/rod"
)

func main() {
	b := rod.New().MustConnect()
	defer b.MustClose()

	printMemStats("before")

	for i := 0; i < 500; i++ {
		page := b.MustPage("about:blank")
		page.MustSetDocumentContent("<html><body>" + strings.Repeat("x", 1024*1024) + "</body></html>")
		page.MustClose()
	}

	runtime.GC()
	printMemStats("after 500 page cycles")
}

func printMemStats(label string) {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("[%s] HeapInuse: %d MB\n", label, m.HeapInuse/1024/1024)
}

Expected: heap stays roughly constant after pages are closed. Actual: heap grows ~1 MB per iteration (the SetDocumentContent params are never freed).

Suggested Fix

Update cleanupStates() to iterate browser.states and delete all entries keyed by stateKey matching the closing page's SessionID:

go
func (p *Page) cleanupStates() {
    p.browser.RemoveState(p.TargetID)

    p.browser.states.Range(func(key, _ interface{}) bool {
        if k, ok := key.(stateKey); ok && k.sessionID == p.SessionID {
            p.browser.states.Delete(key)
        }
        return true
    })
}

sync.Map.Range is documented as safe for concurrent Store/Delete during iteration. This is O(n) over the states map on each page close, but n is small for typical usage (few concurrent pages).

I'm happy to submit a PR with this fix and tests if the approach looks good.

Related

  • #748 — reported the same symptom (memory grows with repeated page open/close on a long-lived browser). The workaround of periodically closing and re-creating the Browser is consistent with this root cause, since closing the browser clears the entire states map.