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 action 10-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)
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:
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:
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:
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:
Detect Failure
The cached action fails (element not found, wrong element, etc.).
Wait for Selector
Stagehand waits briefly in case the element is still loading: await waitForCachedSelector ({
page ,
selector: action . selector ,
timeout: this . domSettleTimeoutMs ,
logger: this . logger ,
context: "act" ,
});
Retry Action
If the selector appears, retry the action. The handler may recalculate the selector: const result = await handler . takeDeterministicAction (
action ,
page ,
this . domSettleTimeoutMs ,
effectiveClient ,
undefined ,
context . variables ,
);
Update Cache
If the action succeeds with a different selector, update the cache: if (
success &&
actions . length > 0 &&
this . haveActionsChanged ( entry . actions , actions )
) {
await this . refreshCacheEntry ( context , {
... entry ,
actions ,
message ,
actionDescription ,
});
}
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:
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:
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 Changes The 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 Content Cached 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 Changes Changing 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.
Clear Cache on Major Changes
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:
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" ,
});
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 );
}
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
});
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