Skip to main content
Stagehand’s caching system replays successful actions without making LLM calls. When pages change, it self-heals by updating selectors automatically.

Why Caching Matters

LLM inference is the slowest part of browser automation:

Without Cache

Every action requires:
  • Capturing the DOM snapshot (100-500ms)
  • LLM inference (500-2000ms)
  • Action execution (50-200ms)
Total: ~1-3 seconds per action

With Cache

Cached actions only need:
  • DOM settle wait (optional, ~100ms)
  • Action execution (50-200ms)
Total: ~150-300ms per action10-20x faster
For repeated workflows (tests, monitoring, data collection), caching dramatically reduces execution time and API costs.

How Caching Works

Stagehand has two cache systems:

1. Act Cache

Caches single act() calls:
// First call: Hits the LLM
await stagehand.act("click the login button");
// Result: Success, selector: #login-btn
// Cache stores: instruction + URL + selector + action

// Second call: Replays from cache
await stagehand.act("click the login button");
// Result: Instant execution using cached selector

Cache Key Generation

The cache key is a hash of:
  • Instruction (exact text)
  • Page URL
  • Variable keys (if using variables)
actCache.ts:183-194
private buildActCacheKey(
  instruction: string,
  url: string,
  variableKeys: string[],
): string {
  const payload = JSON.stringify({
    instruction,
    url,
    variableKeys,
  });
  return createHash("sha256").update(payload).digest("hex");
}
The cache is URL-specific. The same instruction on different pages creates different cache entries.

Cache Storage Format

Each cache entry stores:
actCache.ts:144-154
const entry: CachedActEntry = {
  version: 1,
  instruction: context.instruction,
  url: context.pageUrl,
  variableKeys: context.variableKeys,
  actions: result.actions ?? [],
  actionDescription: result.actionDescription,
  message: result.message,
};
Stored as JSON files in the cache directory (default: ~/.cache/stagehand/).

2. Agent Cache

Caches entire agent workflows:
const agent = stagehand.agent({ cua: true });

// First execution: Runs through LLM
await agent.execute({
  instruction: "search for shoes and add the first result to cart",
});
// Result: 8 steps taken, cached

// Second execution: Replays all steps from cache
await agent.execute({
  instruction: "search for shoes and add the first result to cart",
});
// Result: 8 steps replayed instantly

Agent Cache Key

More complex than act cache:
agentCache.ts:517-533
private buildAgentCacheKey(
  instruction: string,
  startUrl: string,
  options: SanitizedAgentExecuteOptions,
  configSignature: string,
  variableKeys?: string[],
): string {
  const payload = {
    instruction,
    startUrl,
    options,
    configSignature,
    variableKeys: variableKeys ?? [],
  };
  return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
Incorporates:
  • Instruction
  • Starting URL
  • Agent options (maxSteps, highlightCursor, etc.)
  • Config signature (model, tools, integrations, system prompt)
  • Variable keys
Changing the agent model or tools invalidates the cache. This ensures cached workflows match the current configuration.

Recorded Steps

The agent cache records each step type:
agentCache.ts:633-677
switch (step.type) {
  case "act":
    return await this.replayAgentActStep(...);
  case "fillForm":
    return await this.replayAgentFillFormStep(...);
  case "goto":
    await this.replayAgentGotoStep(step, ctx);
    return step;
  case "scroll":
    await this.replayAgentScrollStep(step, ctx);
    return step;
  case "wait":
    await this.replayAgentWaitStep(step);
    return step;
  case "navback":
    await this.replayAgentNavBackStep(step, ctx);
    return step;
  case "keys":
    await this.replayAgentKeysStep(step, ctx);
    return step;
  case "done":
  case "extract":
  case "screenshot":
  case "ariaTree":
    return step;
}
Each step type has a specialized replay handler.

Self-Healing Mechanism

When a cached selector no longer works:
1

Detect Failure

The cached action fails (element not found, wrong element, etc.).
2

Wait for Selector

Stagehand waits briefly in case the element is still loading:
actCache.ts:212-218
await waitForCachedSelector({
  page,
  selector: action.selector,
  timeout: this.domSettleTimeoutMs,
  logger: this.logger,
  context: "act",
});
3

Retry Action

If the selector appears, retry the action. The handler may recalculate the selector:
actCache.ts:219-226
const result = await handler.takeDeterministicAction(
  action,
  page,
  this.domSettleTimeoutMs,
  effectiveClient,
  undefined,
  context.variables,
);
4

Update Cache

If the action succeeds with a different selector, update the cache:
actCache.ts:261-270
if (
  success &&
  actions.length > 0 &&
  this.haveActionsChanged(entry.actions, actions)
) {
  await this.refreshCacheEntry(context, {
    ...entry,
    actions,
    message,
    actionDescription,
  });
}
5

Next Execution Succeeds

Future calls use the updated selector. The cache has “learned” the new page structure.

Example: Button Moved

// Initial page structure:
// <button id="submit">Submit</button>

await stagehand.act("click the submit button");
// Cache stores: selector = #submit

// Page updates:
// <button class="btn-primary">Submit</button>

// Next execution:
await stagehand.act("click the submit button");
// 1. Tries #submit → fails
// 2. Waits for element → timeout
// 3. Re-queries LLM → finds .btn-primary
// 4. Executes action → success
// 5. Updates cache: selector = .btn-primary

// Future executions:
await stagehand.act("click the submit button");
// Uses .btn-primary immediately
Self-healing makes your automation resilient to UI changes without manual updates.

Cache Configuration

Control caching behavior:

Custom Cache Directory

const stagehand = new Stagehand({
  env: "LOCAL",
  cacheDir: "/path/to/custom/cache",
});
Source: v3.ts:352-354

Disable Caching

Set cacheDir: null:
const stagehand = new Stagehand({
  env: "LOCAL",
  cacheDir: null, // No caching
});
Useful for testing or when you want fresh LLM calls every time.

Cache Per Environment

Store separate caches for dev/staging/prod:
const env = process.env.NODE_ENV;
const stagehand = new Stagehand({
  env: "LOCAL",
  cacheDir: `~/.cache/stagehand/${env}`,
});

Cache Hits in History

Cache hits are recorded in history:
await stagehand.act("click the button");

const history = await stagehand.history;
console.log(history[0]);
// {
//   method: "act",
//   parameters: {
//     instruction: "click the button",
//     cacheHit: true,  // ← Cache was used
//   },
//   result: { success: true, ... },
//   timestamp: "2026-02-24T..."
// }
Source: v3.ts:1149-1157

Cache Metrics

Cached executions report zero LLM usage:
const agent = stagehand.agent({ cua: true });

// First run
await agent.execute({ instruction: "do something" });
let metrics = await stagehand.metrics;
console.log(metrics.agentPromptTokens); // e.g., 5000

// Second run (cached)
await agent.execute({ instruction: "do something" });
metrics = await stagehand.metrics;
console.log(metrics.agentPromptTokens); // Still 5000 (no new tokens)
Cache hits explicitly set tokens to 0:
agentCache.ts:597-603
result.usage = {
  input_tokens: 0,
  output_tokens: 0,
  reasoning_tokens: 0,
  cached_input_tokens: 0,
  inference_time_ms: 0,
};
Track totalPromptTokens before and after to measure cache effectiveness.

Variables & Cache

Variables work with caching:
// First call with variable
await stagehand.act(
  "type %email% into the email field",
  { variables: { email: "[email protected]" } }
);
// Cache stores: instruction + variableKeys: ["email"]

// Second call with DIFFERENT value
await stagehand.act(
  "type %email% into the email field",
  { variables: { email: "[email protected]" } }
);
// Cache HIT: Same instruction, same variableKeys
// Replays action with new email value
How it works:
actCache.ts:96-119
const entryVariableKeys = Array.isArray(entry.variableKeys)
  ? [...entry.variableKeys].sort()
  : [];
const contextVariableKeys = [...context.variableKeys];

if (!this.doVariableKeysMatch(entryVariableKeys, contextVariableKeys)) {
  return null; // Cache miss: variable keys changed
}

if (
  contextVariableKeys.length > 0 &&
  (!context.variables ||
    !this.hasAllVariableValues(contextVariableKeys, context.variables))
) {
  // Cache miss: missing required variables
  return null;
}
The cache compares variable keys, not values. Different values with the same keys reuse the cache.

Cache Limitations

URL ChangesThe cache is URL-specific. If the page URL changes, it’s a cache miss:
// Page 1
await page.goto("https://example.com/login");
await stagehand.act("click login");
// Cached for https://example.com/login

// Page 2
await page.goto("https://example.com/signin");
await stagehand.act("click login");
// Cache miss: different URL
Use query-string-normalized URLs if possible.
Dynamic ContentCached selectors assume consistent element structure. For highly dynamic pages (infinite scroll, real-time updates), caching may miss.Consider disabling cache for such pages:
const stagehand = new Stagehand({ cacheDir: null });
Agent Configuration ChangesChanging agent options invalidates the cache:
// First run
const agent1 = stagehand.agent({ model: "openai/gpt-4.1" });
await agent1.execute({ instruction: "do X" });
// Cached

// Second run with different model
const agent2 = stagehand.agent({ model: "anthropic/claude-3-5-sonnet" });
await agent2.execute({ instruction: "do X" });
// Cache miss: different model
This ensures the cache doesn’t serve results from a different model/configuration.

Cache Storage Internals

The CacheStorage class handles file I/O:
cacheStorage.ts (conceptual)
export class CacheStorage {
  private readonly cacheDir: string | null;
  private readonly logger: Logger;
  public readonly enabled: boolean;

  constructor(cacheDir: string | null, logger: Logger) {
    this.cacheDir = cacheDir;
    this.logger = logger;
    this.enabled = cacheDir !== null;
  }

  async readJson<T>(filename: string): Promise<{ value: T | null; error?: Error; path?: string }> {
    if (!this.enabled) return { value: null };
    const path = join(this.cacheDir!, filename);
    try {
      const content = await readFile(path, "utf-8");
      return { value: JSON.parse(content), path };
    } catch (error) {
      return { value: null, error: error as Error, path };
    }
  }

  async writeJson(filename: string, data: unknown): Promise<{ error?: Error; path?: string }> {
    if (!this.enabled) return {};
    const path = join(this.cacheDir!, filename);
    try {
      await mkdir(this.cacheDir!, { recursive: true });
      await writeFile(path, JSON.stringify(data, null, 2), "utf-8");
      return { path };
    } catch (error) {
      return { error: error as Error, path };
    }
  }
}
Cache files are human-readable JSON, making debugging easier.

Best Practices

Keep instruction wording consistent for cache hits:
// Good: Consistent
await stagehand.act("click the submit button");
await stagehand.act("click the submit button"); // Cache hit

// Bad: Different wording
await stagehand.act("click the submit button");
await stagehand.act("press the submit button"); // Cache miss
Strip query params or fragments that don’t affect the page:
const url = new URL(page.url());
url.search = ""; // Remove ?query=...
url.hash = "";   // Remove #section
await page.goto(url.toString());
This increases cache hit rate across sessions.
Parameterize values so the cache works across different inputs:
// Good: Variable
await stagehand.act("type %username% into the username field", {
  variables: { username: user },
});

// Bad: Hardcoded
await stagehand.act(`type ${user} into the username field`);
The first approach reuses the cache; the second creates a new entry per user.
If you redesign your site, clear the cache:
rm -rf ~/.cache/stagehand/
Or programmatically:
import { rm } from "fs/promises";
await rm("~/.cache/stagehand", { recursive: true, force: true });
Track how often the cache is used:
let hits = 0;
let misses = 0;

for (const action of actions) {
  const result = await stagehand.act(action.instruction);
  const history = await stagehand.history;
  const lastEntry = history[history.length - 1];
  if (lastEntry.parameters.cacheHit) {
    hits++;
  } else {
    misses++;
  }
}

console.log(`Cache hit rate: ${(hits / (hits + misses) * 100).toFixed(1)}%`);

Cache in Production

For production deployments:
1

Persist Cache Across Runs

Store the cache in a shared volume or persistent directory:
const stagehand = new Stagehand({
  env: "BROWSERBASE",
  cacheDir: "/mnt/shared/stagehand-cache",
});
2

Pre-warm the Cache

Run critical workflows once to populate the cache before going live:
// Warm-up script
const criticalFlows = [
  "login as admin",
  "navigate to dashboard",
  "export data",
];

for (const flow of criticalFlows) {
  await stagehand.act(flow);
}
3

Monitor Self-Healing

Log when selectors change to detect UI regressions:
stagehand.on("cache:refresh", (entry) => {
  console.warn(`Selector changed for: ${entry.instruction}`);
  // Alert your team about potential UI change
});
4

Version Your Cache

Include a version in the cache key for major app updates:
const stagehand = new Stagehand({
  cacheDir: `~/.cache/stagehand/v${APP_VERSION}`,
});

Next Steps

Understand How Stagehand Works

Learn the full architecture

Write Effective Instructions

Optimize for cache hits

Metrics & Observability

Track cache performance

Production Deployment

Deploy with caching best practices

Build docs developers (and LLMs) love