July 16, 2026
How to Test Browser Storage, Refresh State, and Rehydration Without Breaking E2E Stability
A practical tutorial on testing localStorage, sessionStorage, refresh state, hard reloads, and rehydration bugs in Playwright, with patterns that keep E2E suites stable.
Modern web apps do not just load data, they remember it. A user toggles a filter, refreshes the page, comes back in a new tab, or resumes after a browser crash, and the app is expected to restore enough state to feel continuous. That continuity is often implemented with localStorage, sessionStorage, URL state, in-memory caches, persisted query stores, or a combination of all four.
That is where the testing problem gets interesting. The app can pass a happy-path E2E flow and still fail at the exact moment users care about most, after refresh, after navigation, or during rehydration. These failures are easy to miss in unit tests, hard to reproduce manually, and often flaky in browser automation if the test is written without understanding how browser storage actually behaves.
This article is a practical guide to how I approach browser storage testing in Playwright, with enough detail to avoid the common traps. The goal is not to test the browser itself. The goal is to verify that the app stores, restores, and invalidates state correctly, while keeping the suite stable enough to run in CI.
The key idea is simple, test the state boundary, not every implementation detail. If a refresh should preserve the user experience, assert that contract at the right seam and avoid over-specifying how the app gets there.
What you are really testing when you test browser storage
When people say they want to test browser storage, they usually mean one of these behaviors:
localStoragepersists across reloads and browser restartssessionStoragesurvives same-tab navigation and reloads, but not a new tab or a new browser session- the app rehydrates from persisted state without losing user-visible data
- expired or corrupted stored state is discarded safely
- auth tokens, feature flags, draft edits, and UI preferences are read back correctly
Each one has different failure modes. A bug in a persisted cart is not the same as a bug in a search filter that comes back after refresh. A broken rehydration path might only appear after a hard reload, when the app starts from empty memory and reconstructs the UI from storage plus server data.
Playwright is a good fit here because it gives you direct control over the browser context, storage state, and page lifecycle. The official docs for Playwright are worth keeping open, especially the parts on browser contexts and authentication state.
Know the storage model before you automate it
A lot of flaky tests come from mixing storage types as if they behaved the same.
localStorage
localStorage is origin-scoped and persistent across browser sessions. It is useful for preferences, cached drafts, and some auth flows, although many teams should prefer HttpOnly cookies for real authentication. Because it persists, it can also poison tests if you reuse the same context or profile without resetting state.
sessionStorage
sessionStorage is scoped to the tab or browsing context. It survives reloads in the same tab, but not a new tab. That makes it useful for ephemeral workflow state, but it also makes tests more fragile if your test accidentally opens a new page instead of reusing the same one.
In-memory state
A lot of modern apps hydrate component state from the server or from a client cache like Redux, Zustand, React Query, Apollo, or a custom store. That state may be derived from browser storage, but it is not the same thing. A test that only checks storage can still miss a rehydration bug, where the storage value is present but the UI does not reflect it after the app boots.
URL state
Do not ignore the URL. Query parameters and hashes are often part of a persistence strategy, especially for filters, pagination, and selected entities. In practice, a robust test often checks both the URL and browser storage because a refresh can reconstruct state from either source.
The main failure modes after refresh and reload
Here are the bugs I look for first.
1. Stored state exists, UI does not rehydrate
The app writes to localStorage, but on refresh the component initializes before the storage read completes, or the store is never repopulated. The user sees defaults, while the browser still contains the previous value.
2. UI rehydrates, but from stale state
The app loads old data from storage and never invalidates it after schema changes, logout, feature flag changes, or backend migrations. This often shows up after a deploy, not during local testing.
3. Hard reload behaves differently from soft reload
A soft reload may keep in-memory cache around longer than you expect, while a hard reload forces the app to rebuild from scratch. If the behavior is different, you may have hidden dependency on memory instead of persisted state.
4. New tab versus same tab behavior is wrong
This is common with sessionStorage. A flow might work after page.reload(), but fail when the user opens the app in a new tab and expects a clean session.
5. Storage leaks between tests
A previous spec leaves behind localStorage entries or authenticated storage state. The next test passes locally but fails in CI because ordering changed, a worker reused the same profile, or some test setup reused a context.
6. Rehydration races
The app renders a shell, reads persisted state asynchronously, then patches the UI. A test that checks too early sees default values and reports a false failure. A test that waits too long hides a real regression.
Decide what belongs in E2E and what should not
I keep E2E focused on user-visible contracts.
Good E2E candidates:
- preferences remain after refresh
- drafts survive reload
- selected filters restore correctly
- logout clears protected state
- invalid storage is handled gracefully
- a hard reload still returns the app to the correct screen
Poor E2E candidates:
- every individual key stored in
localStorage - the exact serialization format of cached objects
- testing storage through private internal helper functions
- asserting implementation details that can change without user impact
If you want maximum stability, test the behavior at the boundary. For example, a filter state test should verify that the UI and URL restore correctly after refresh, not that a specific reducer wrote a particular JSON shape into storage. The latter belongs in unit or integration tests.
A Playwright pattern that keeps storage tests stable
The easiest way to make these tests stable is to isolate browser contexts and explicitly control the persisted state. Playwright gives you storageState, which is especially useful for auth and preloaded sessions.
Here is a simple pattern for starting clean, setting state, reloading, and verifying the app rehydrates:
import { test, expect } from '@playwright/test';
test('restores draft text after refresh', async ({ page }) => {
await page.goto('/notes/123');
await page.getByLabel('Draft').fill('Persist me');
await page.reload();
await expect(page.getByLabel(‘Draft’)).toHaveValue(‘Persist me’); });
That looks trivial, but the value is in what it does not assume. It does not inspect internal storage first. It asserts the visible contract after a refresh, which is what users experience.
For localStorage, you sometimes need to inspect or seed state directly. Use this carefully.
import { test, expect } from '@playwright/test';
test('reads theme preference from localStorage', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
await page.reload();
await expect(page.locator(‘html’)).toHaveAttribute(‘data-theme’, ‘dark’); });
That is useful when the UI is supposed to reflect a persisted preference, but it is still better to validate the rendered outcome than to stop at the storage entry.
Testing localStorage without making the suite brittle
localStorage is often overused, but it is still common. The practical problems are usually not the API itself, they are test isolation and lifecycle.
Seed state through the browser context when you can
If you need a pre-authenticated session or a known set of preferences, prefer using Playwright’s storage state support and browser context setup rather than sprinkling evaluate() calls through tests. That keeps setup reusable and makes the test intent clearer.
Clear storage at the right time
Do not rely on a vague “cleanup after the suite” habit. Storage should be isolated per test or per worker depending on what you are validating. If a test intentionally checks persistence across reload, it should still start from a clean context.
Assert behavior after app boot, not immediately before it
A common anti-pattern is checking localStorage and then immediately checking the DOM without allowing the app to finish hydrating. If the app initializes asynchronously, that creates race conditions. Use a meaningful UI condition, such as a visible element or expected route, rather than a fixed sleep.
Handle schema versioning
If your app stores JSON with a version field, test the upgrade and fallback behavior. A stale record from an older build can be a real production scenario, especially if users keep tabs open for a long time.
A good test case is not just “value exists,” but “old value is either migrated or discarded safely.”
Testing sessionStorage in the same tab and across tabs
sessionStorage bugs are sneaky because they hide behind tab semantics.
Same-tab reload should preserve state
If the app uses sessionStorage for a form wizard or temporary draft, reload should keep it in the same tab.
import { test, expect } from '@playwright/test';
test('keeps wizard step in sessionStorage after reload', async ({ page }) => {
await page.goto('/wizard');
await page.getByRole('button', { name: 'Next' }).click();
await page.reload();
await expect(page.getByText(‘Step 2’)).toBeVisible(); });
New tab should not inherit session state
This is where tests often accidentally lie. If your app opens a new tab or the user copies a link into a new tab, sessionStorage should be empty in that new context.
import { test, expect } from '@playwright/test';
test('does not leak sessionStorage into a new tab', async ({ context, page }) => {
await page.goto('/wizard');
await page.evaluate(() => sessionStorage.setItem('step', '2'));
const newPage = await context.newPage(); await newPage.goto(‘/wizard’);
await expect(newPage.getByText(‘Step 1’)).toBeVisible(); });
That distinction matters because a lot of production bugs are not “storage broken,” they are “storage scoped incorrectly for the user journey.”
Rehydration bugs are usually timing bugs in disguise
Rehydration is the point where the app rebuilds visible state from storage, server data, and runtime initialization. Bugs here often come from ordering.
Common patterns include:
- the app renders defaults first, then replaces them later
- persisted state is read before async data is available, then overwritten
- a cache hydrates from storage and then gets replaced by a stale network response
- a component unsubscribes before the restore completes
The test strategy is to assert the final state after the app has reached a stable condition. If the application exposes a loading indicator, wait for that to finish before checking the persisted UI.
import { test, expect } from '@playwright/test';
test('rehydrates selected project after page load', async ({ page }) => {
await page.goto('/projects');
await page.evaluate(() => localStorage.setItem('selectedProjectId', 'p-42'));
await page.reload();
await expect(page.getByRole(‘heading’, { name: ‘Project 42’ })).toBeVisible(); });
If this test is flaky, the problem is usually not Playwright. It is often a missing readiness signal in the app.
If you cannot define a reliable “app is ready” signal, your UI tests will guess. Guessing is how flake enters the suite.
How to test refresh, hard reload, and tab restore differently
These three operations sound similar but they exercise different paths.
Refresh
A regular reload verifies that state survives a standard page lifecycle reset. It is the right choice for most persistence flows.
Hard reload
A hard reload is useful when you suspect the app depends on memory, cached modules, or a warm execution path. In Playwright, you can simulate a harder reset by creating a fresh page or context instead of assuming the browser behaves like a same-tab reload.
Tab restore
Tab restore is harder to simulate precisely in automation, but you can approximate its important effect, a browser comes back with persisted storage but not in-memory app state. A fresh context plus preloaded storage state is often the closest practical model.
The broader lesson is that the browser event is not the real contract. The real contract is, what must the user see and what state should remain after the app restarts?
Guard against contamination in CI
Storage-related E2E failures often become obvious only in CI, where test order, parallel workers, and environment reuse are less forgiving.
Use these practices:
- Create a fresh browser context for each test where possible
- Do not share mutable storage across unrelated specs
- Keep auth setup separate from feature tests
- Use deterministic test data, not leftovers from prior runs
- Avoid depending on the same user profile across workers
A minimal GitHub Actions job for Playwright usually needs stable browser installation, repeatable test execution, and enough visibility to debug storage-related failures later.
name: e2e
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test
For general context on why this fits into a wider delivery pipeline, the idea of continuous integration is to surface integration failures quickly, before state leaks become difficult to untangle.
When a storage test should fail fast
Not every persistence bug deserves a long exploratory flow. Some failures should be immediate and loud.
Examples:
- a corrupted JSON value causes a render crash
- a missing key causes the app to show a blank state instead of a recoverable fallback
- expired auth state leaves the app in a broken half-logged-in state
- migrated schema breaks old persisted data
For those cases, write a test that sets up the bad state directly and verifies the app handles it. That is more valuable than only testing the happy path.
import { test, expect } from '@playwright/test';
test('falls back when stored state is invalid', async ({ page }) => {
await page.goto('/settings');
await page.evaluate(() => localStorage.setItem('settings', '{bad json'));
await page.reload();
await expect(page.getByText(‘Reset your settings’)).toBeVisible(); });
This is one of the few cases where a direct storage mutation is the right move. You are testing resilience, not just correctness.
A practical checklist for stable storage and rehydration tests
Before adding a new test, I ask these questions:
- What user-visible behavior should persist across refresh?
- Is the state stored in
localStorage,sessionStorage, URL state, or memory? - Should the state survive a new tab, or only the same tab?
- What signal means the app has finished rehydrating?
- What stale or invalid stored data should the app reject?
- Could another test leave behind the same key or account state?
- Is this a behavior test, or am I accidentally testing implementation details?
If you cannot answer the last question clearly, the test is probably too coupled to internals.
A sane division of labor between test layers
I prefer this split:
- unit tests, verify serializers, reducers, migrations, and storage adapters
- integration tests, verify the app can read and write the right keys under realistic conditions
- E2E tests, verify the user sees the correct state after refresh, reload, or tab changes
That division matters because rehydration bugs cross layers. A reducer can be correct, the storage adapter can be correct, and the E2E flow can still fail because the app boots in the wrong order or a race condition overwrites the restored state.
Final opinion, keep the test close to the contract
If the app promises persistence, test the persistence from the user’s point of view. If it promises reset behavior, test that the old state disappears when expected. If it promises same-tab continuity but not cross-tab continuity, encode that distinction explicitly.
The strongest suites I see are not the ones with the most storage assertions. They are the ones that define a small number of durable contracts, isolate browser context cleanly, and avoid brittle guesses about internal timing.
That is the practical way to test browser storage state in Playwright without turning your E2E suite into a source of noise. Keep the boundary clear, model the browser lifecycle honestly, and make rehydration a first-class behavior instead of an afterthought.