Puppeteer Local Storage: Set, Save and Restore Before Load

Replaced broken advanced examples and unsupported performance claims with a tested Chrome packet covering startup timing, state restoration, origin errors and extracted records. See the captured results.
Use page.evaluate() to read or change localStorage in an already loaded Puppeteer page. To supply a value before the application's scripts run, install an origin-guarded page.evaluateOnNewDocument() callback before navigation. Pass values as arguments; a function executing in the page cannot implicitly use your Node.js variables or helpers.
Then check the data you meant to extract. Our catalog returned four rows with a captured HTTP 200 status in every extraction observation, yet 15 of the 36 observations contained the deliberately selected wrong dataset. A successful write to localStorage was not enough to update the page.
Which localStorage operation do you need?
These API patterns assume an existing page on the intended HTTP(S) origin. They are small operation fragments; the complete runnable example follows.
| Task | Pattern |
|---|---|
| Read a string | await page.evaluate(key => localStorage.getItem(key), 'region') |
| Set a string on the current page | await page.evaluate(([key, value]) => localStorage.setItem(key, value), ['region', 'EU']) |
| Remove one key | await page.evaluate(key => localStorage.removeItem(key), 'region') |
| Clear that origin intentionally | await page.evaluate(() => localStorage.clear()) |
| Initialize before application scripts | Install evaluateOnNewDocument before goto |
| Save and restore selected state | Export an exact origin plus string entries; validate before installing |
LocalStorage stores strings and is scoped to an origin. For structured data, use JSON.stringify on the value and JSON.parse after reading a nonmissing string. Our JSON diagnostic passed quotes, a backslash, a newline, Unicode and script-like text as an argument; the object round-tripped and the script-like text remained data. That is a concrete regression probe, not a general security guarantee.
Prefer removing a specific preference when other application state should remain. A full clear resets that origin's whole localStorage area. Neither operation automatically makes the application refetch its data.
Fix “Access is denied” by establishing the right origin
In the captured diagnostic, a newly created page at about:blank raised SecurityError when the script tried to use localStorage. After navigation to the owned HTTP origin, the same page could write and read EU.
That result gives a useful first check: inspect the current page URL and origin before treating an access error as a Puppeteer defect. A blank or unsuitable document is not the target site's storage area. The test did not disable browser security to fix access. Different browser policies can also affect storage; the measured recovery here is specifically navigation from the new blank page to the fixture's HTTP origin.
When the target application must see the value at startup, use a preload callback that waits for a matching document. This is the complete initializer from state.mjs:
export async function installSnapshot(page, snapshot, intendedOrigin) {
validateSnapshot(snapshot, intendedOrigin);
return page.evaluateOnNewDocument(data => {
if (location.origin !== data.origin) return;
for (const [key, value] of data.entries) localStorage.setItem(key, value);
}, snapshot);
}
The adjacent validateSnapshot helper accepts a canonical HTTP(S) origin and string pairs, rejects duplicate keys and checks the intended origin. Puppeteer calls evaluateOnNewDocument before the page's own scripts. The origin check prevents the callback from writing the same values on an unrelated page visited later.
This initializer writes its entries on every matching new document. It can overwrite a later preference change; it is a forced seed, not a missing-only default. Keep the identifier field from the returned object if you need to remove the initializer with page.removeScriptToEvaluateOnNewDocument(identifier) after its job is done.
Run the save-and-restore example
Use Node 22.12 or newer and check out the exact evidence snapshot:
git clone https://github.com/ScrapingAnt/scrapingant-examples.git
cd scrapingant-examples
git checkout 5dcd4058ba609fd86d7ffaad98deb0d8d2df6714
cd examples/puppeteer-local-storage
npm ci
./run.sh
The pinned package is Puppeteer 25.12.0. Its normal installation supplies bundled Chrome; Linux also needs the browser's system libraries. The packet's README links the official setup guidance and documents the explicit CI sandbox override. Ordinary local runs retain the sandbox. The first hosted Ubuntu run failed at browser launch; the CI workflow scopes its opt-out to this synthetic loopback packet.
The runner starts its own local servers, runs boundary tests, executes the quickstart and performs three matrix rounds. It writes run_output/ without replacing committed captures. No API key, external target or account is needed.
Here is the entire quickstart.mjs, run with the adjacent helpers:
// Complete local-only example: install state before app scripts, then restore it.
import assert from 'node:assert/strict';
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { startFixture } from './fixture.mjs';
import { captureSnapshot, encodeSnapshot, decodeSnapshot, installSnapshot, scoreRecords } from './state.mjs';
import { launchBrowser, environment, navigateCatalog } from './browser_helpers.mjs';
const fixture = await startFixture();
const temporary = await mkdtemp(join(tmpdir(), 'synthetic-puppeteer-quickstart-'));
const stateFile = join(temporary, 'storage.json');
let browser, result;
try {
browser = await launchBrowser();
const source = await browser.createBrowserContext();
const page = await source.newPage();
await installSnapshot(page, { origin: fixture.origin, entries: [['region', 'EU']] }, fixture.origin);
await page.goto(fixture.origin + '/blank');
await writeFile(stateFile, encodeSnapshot(await captureSnapshot(page)), { mode: 0o600 });
await source.close();
const restoredContext = await browser.createBrowserContext();
const restored = await restoredContext.newPage();
const snapshot = decodeSnapshot(await readFile(stateFile, 'utf8'), fixture.origin);
await installSnapshot(restored, snapshot, fixture.origin);
const restoredData = await navigateCatalog(restored, fixture);
// This fixture also exposes an explicit region URL; it does not write storage.
const queryContext = await browser.createBrowserContext();
const queryPage = await queryContext.newPage();
const queryData = await navigateCatalog(queryPage, fixture, '/catalog?region=EU');
result = {
environment: await environment(browser),
restored: { ...restoredData, ...scoreRecords(restoredData.records) },
query_url: { ...queryData, ...scoreRecords(queryData.records) },
};
assert.equal(result.restored.exact_match, true);
assert.equal(result.query_url.exact_match, true);
assert.equal(result.query_url.storage_length, 0);
} finally {
try { if (browser) await browser.close(); }
finally { await fixture.close(); await rm(temporary, { recursive: true, force: true }); }
}
result.temporary_snapshot_removed = await access(stateFile).then(() => false, () => true);
const output = process.argv[2] ?? 'run_output/quickstart.json';
await mkdir(dirname(output), { recursive: true });
await writeFile(output, JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result, null, 2));
The first context writes synthetic state, saves a small JSON file and closes. A new context reads the file and installs the validated entries before visiting the catalog. The last context visits a supported region URL without any storage. The temporary file is created with mode 0o600 and removed during cleanup; this is not encryption or a complete browser-profile backup.
Selected fields from the actual captured output:
{
"restored": {
"storage_region": "EU",
"storage_length": 1,
"matching_records": 4,
"exact_match": true
},
"query_url": {
"storage_region": null,
"storage_length": 0,
"matching_records": 4,
"exact_match": true
},
"temporary_snapshot_removed": true
}
Both routes extracted the literal target tuples SKU-101 / EUR / 8.10, SKU-202 / EUR / 16.20, SKU-303 / EUR / 24.30 and SKU-404 / EUR / 32.40. The scorer defines those independently of the server's pricing code. It compares SKU, currency and price together, rejecting duplicate substitutes, missing rows and extras.
The storage-to-data experiment
The fixture application chooses its URL region first, then localStorage.region, then US. Its JavaScript explicitly sends that choice to /api/catalog and renders the response. The localStorage value is not itself an HTTP request field. This page reads its preference once during startup and does not subscribe to storage changes.
The primary matrix has 36 extraction observations and 12 diagnostics: 12 extraction cases and four diagnostics, repeated three times in bundled Chrome. Ten extraction cases read the browser DOM; two make direct Node HTTP requests. Each row below represents three observations with the same result.
| Case | Stored region | Returned/rendered dataset | EUR matches | New catalog requests per observation |
|---|---|---|---|---|
| Fresh page | Missing | USD | 0/4 | 1 |
| Initializer before app startup | EU | EUR | 4/4 | 1 |
| Late write, then reread existing DOM | EU | USD | 0/4 | 0 |
| Reload after that write | EU | EUR | 4/4 | 1 |
| Restore into a fresh context | EU | EUR | 4/4 | 1 |
| New same-origin page in source context | EU | EUR | 4/4 | 1 |
| New isolated context | Missing | USD | 0/4 | 1 |
| Other port, guarded initializer | Missing | USD | 0/4 | 1 |
| Return to original origin, initializer still installed | EU | EUR | 4/4 | 1 |
Fresh context, /catalog?region=EU | Missing | EUR | 4/4 | 1 |
Direct /api/catalog?region=EU | No browser | EUR | 4/4 | 1 |
Direct /api/catalog | No browser | USD | 0/4 | 1 |
The stale-DOM observations reused the existing response: they made no new catalog request. The primary counters therefore total 33 catalog requests, not 36. The quickstart is separate and excluded from these counts.
All 48 designed checks passed. Twenty-one extraction observations matched all four EUR tuples; the other 15 matched a separately defined USD control dataset. Those wrong-state controls are intentional, not evidence of a browser failure rate. The raw capture records startup source, stored value, request count and extracted rows so those stages can be checked separately.
The return-to-origin row is also deliberately limited: the initializer is still installed, so it can write again. That row cannot distinguish retained state from reapplication. By contrast, the new same-context page has no initializer of its own and reads the shared same-origin value. The separate context starts empty.
Storage events do not rerender your application for you
The event diagnostic uses two pages sharing the same origin and context. One writes a probe key. The other receives the event and writes an acknowledgement. When the writer receives that acknowledgement, the other page has recorded one probe event and the writer has recorded none. This happened in all three rounds.
That boundary avoids declaring success after an arbitrary sleep, and it agrees with MDN's storage-event behavior: the event is delivered to other relevant documents, not the document that made the change. It does not mean the application has listened to the event or refreshed a table.
For our catalog, reload is what rereads the preference and requests EU data. In another application, a UI action or application-specific refresh may be required. Wait for the actual expected records after that action. Watching only localStorage.region === 'EU' would accept the stale USD table in this experiment.
Keep Node.js work outside the page callback
The separate scope diagnostic called a Node-side lexical helper from inside page.evaluate and received ReferenceError. Computing the string in Node first and passing it as an explicit evaluation argument stored and returned the exact value.
Puppeteer's page functions execute in the browser environment. Put file access and other Node work outside that function, then pass the necessary serializable data into it. The quickstart follows that pattern: Node reads and validates the JSON snapshot; the page callback receives only the origin and string entries it needs. No Node crypto function is assumed to exist in the page.
When ScrapingAnt fits the extraction task
The URL-selected control provides the product connection: a fresh page at /catalog?region=EU rendered the same desired records with empty localStorage. If your target already supports an equivalent URL, you may not need custom storage control for that retrieval task. Verify that equivalence from the records; this fixture's query parameter is not a universal trick for other sites.
With browser=true, ScrapingAnt renders the target page with JavaScript and returns its HTML. With browser=true, wait_for_selector tells ScrapingAnt to wait for the specified DOM element to appear. For a view that works from its URL, ScrapingAnt's browser rendering can handle the rendered-HTML retrieval step. Keep the record checks in your pipeline, because a ready table can still contain the wrong prices.
ScrapingAnt does not support localStorage operations. Keep browser automation when your task requires setting, exporting or restoring that state. For sites whose required state is actually cookie-based, ScrapingAnt accepts cookies as name/value pairs through the cookies parameter, with multiple pairs separated by semicolons. A saved localStorage JSON object is not a cookie jar or a supported storage import.
A request with JavaScript rendering through a datacenter proxy costs 10 API credits. That is the documented cost, not spending from this experiment. No retrieval service received these fixtures. ScrapingAnt is unnecessary for the local example or a direct request you already handle; its useful role here begins when the desired view needs rendered HTML but no custom localStorage operations.
What these results do and do not establish
The packet additionally passed 24 boundary tests, outside the matrix denominator. They exercise snapshot inputs, full tuple matching, missing/duplicate cases, contradictory captures and corrupt USD controls that would otherwise hide behind zero EUR matches.
This is one Chrome version, one host and a deterministic synthetic application. Firefox, real login state, browser-profile restarts, quota, encryption, sessionStorage and IndexedDB were not measured. Three repetitions establish the observed mechanism, not production reliability or a speed comparison. The snapshot restores selected values for the same running origin; it is not proof of portable authentication.
For related tasks, see Puppeteer cookies, Playwright localStorage and Selenium localStorage.
Examples tested on 2026-09-27 with Node 22.20.0, Puppeteer 25.12.0 and bundled Chrome 154.0.8037.57 on macOS 26.6.2 arm64. Code and evidence.
This article was drafted with AI assistance from a tested evidence packet. Oleg Kulyk is responsible for the article, measurements and corrections.