July 11, 2026
How to Test Browser Storage Persistence Across Refreshes, Subdomains, and Session Expiration
Learn how to test browser storage persistence with localStorage, sessionStorage, and cookies across refreshes, subdomains, logout, and session expiration using practical QA patterns.
Browser storage bugs tend to hide in the gaps between pages, tabs, and domains. A feature can look correct on first load, then fail after a refresh, after a redirect from a subdomain, or after a session expires in the middle of a workflow. That is why teams that build auth-heavy applications need to test browser storage persistence as a first-class behavior, not as an afterthought.
This matters for login state, feature flags, onboarding progress, cart contents, draft forms, and any workflow that depends on localStorage, sessionStorage, or cookies. The tricky part is that each storage mechanism has different lifetime rules, different scope, and different failure modes. A test that only checks the happy path will often miss the bug where a token survives too long, disappears too early, or is not shared across the right subdomains.
The goal is not just to prove storage exists, it is to prove the app behaves correctly when the browser reloads, navigates, isolates tabs, and loses session state.
What you should verify first
Before writing automation, decide which behaviors actually matter for your application. For most teams, the core test matrix is small but meaningful:
- Refresh on the same page
- Navigate between pages in the same origin
- Move between subdomains, if the app uses them
- Open a new tab or window
- Close and reopen the browser session
- Log out and verify state is cleared
- Wait for session expiration and verify reauthentication
If your app uses SSO, you should also check redirects across auth domains and whether the return path restores the intended state.
Know the storage model before you test it
Testing is easier when the expected behavior is explicit.
localStorage
localStorage is origin-scoped and persists across browser restarts until cleared. It is often used for theme settings, cached preferences, and sometimes auth tokens, although storing long-lived credentials there has security tradeoffs.
Good tests for localStorage include:
- Value survives a hard refresh
- Value survives closing and reopening the tab
- Value is isolated by origin
- Value is cleared on logout if your app requires it
sessionStorage
sessionStorage is tied to a top-level browsing context. It usually survives refreshes, but not a new tab or a full browser session restart. It is often used for in-progress form state, wizard steps, or temporary auth handoff values.
Good tests for sessionStorage include:
- Value survives refresh
- Value does not leak into a new tab
- Value is removed when the tab is closed
- Value is not reused after a fresh browser session
Cookies
Cookies can be scoped by domain, path, expiration, Secure, HttpOnly, and SameSite. For auth-heavy apps, cookies are often the most important storage mechanism to validate because they can drive login persistence and cross-subdomain behavior.
Good tests for cookies include:
- Cookie is present after login
- Cookie is sent on the expected domain or subdomain
- Expiration matches your session policy
- Logout clears or invalidates the cookie
SameSitebehavior matches your redirect flow
For a quick refresher on the broader discipline, see software testing and test automation.
Design your test around observable behavior
A common mistake is to check storage directly and stop there. Storage inspection is useful, but the real user impact is what matters.
For example:
- A token remaining in
localStorageis not enough if the app still sends the user to the login screen because the token is expired. - A cookie being set is not enough if the cookie is on the wrong subdomain and the API never receives it.
- A
sessionStoragevalue surviving refresh is not enough if a second tab incorrectly reuses it.
The best tests combine storage assertions with visible app behavior:
- The UI should still show the signed-in state after refresh
- The protected page should remain accessible after reload
- A new tab should either prompt login or start empty, depending on your design
- After expiration, the app should redirect to login or show a clear session-expired state
When storage and UI disagree, the storage value is usually not the bug. The bug is that the application trusted it incorrectly.
A practical test matrix for web apps
Use this matrix as a starting point for manual checks and automation coverage.
| Scenario | localStorage | sessionStorage | Cookie | What to watch for |
|---|---|---|---|---|
| Refresh same page | should persist | should persist | should persist if session is active | state loss, duplicate login, flash of logged-out UI |
| Open new tab | should persist | should not persist | should persist if cookie scoped correctly | accidental session reuse, missing handoff |
| Close browser and reopen | should persist | should not persist | depends on expiration | stale login, invalid session accepted |
| Navigate to subdomain | origin-scoped, so usually separate | separate | may or may not share based on domain | broken SSO, missing cookie scope |
| Logout | should be cleared or ignored | should be cleared | should be cleared or invalidated | ghost session, rehydrated private data |
| Session expiry | may still contain data | may still contain data | should expire or become invalid | app trusts stale client storage |
Use this table as a specification. If your app intentionally behaves differently, write that down. The point is consistency, not a universal rule.
Test same-page refresh behavior
Refreshing a page sounds simple, but it exposes a lot of hidden state bugs. In apps with hydration, SPA routing, or lazy auth checks, the UI can briefly render with stale state before the app reconciles with actual storage.
What to verify
- The page reloads without losing the intended state
- No duplicate login prompts appear
- The app does not briefly show the wrong user or role
- The app does not create a second token or overwrite existing values
Example Playwright check
import { test, expect } from '@playwright/test';
test('keeps signed-in state after refresh', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
const tokenBefore = await page.evaluate(() => localStorage.getItem(‘auth_token’)); await page.reload();
await expect(page.getByText(‘Welcome back’)).toBeVisible(); const tokenAfter = await page.evaluate(() => localStorage.getItem(‘auth_token’)); expect(tokenAfter).toBe(tokenBefore); });
This test checks both the visible state and the stored token. If the UI survives but the token changes unexpectedly, you may be silently reissuing sessions on every refresh, which can make debugging expiration issues much harder.
Test sessionStorage correctly across tabs
sessionStorage is where many teams get surprised. It can survive a refresh, so a test that only reloads the page may pass even when the storage behavior is wrong.
If the business rule says temporary data should not cross tabs, you need a separate tab test.
Example with Playwright contexts
import { test, expect } from '@playwright/test';
test('sessionStorage does not leak into a new tab', async ({ browser }) => {
const context = await browser.newContext();
const page1 = await context.newPage();
await page1.goto(‘https://app.example.com/wizard’); await page1.evaluate(() => sessionStorage.setItem(‘draft_step’, ‘2’));
const page2 = await context.newPage(); await page2.goto(‘https://app.example.com/wizard’);
const draftStep = await page2.evaluate(() => sessionStorage.getItem(‘draft_step’)); expect(draftStep).toBeNull(); });
If the new tab sees the same sessionStorage value, check whether the test setup accidentally reused a browsing context. Isolation bugs in tests can hide real isolation bugs in production.
Test subdomain handoff explicitly
Subdomain behavior is one of the most common sources of confusion because storage and cookies do not follow the same rules.
Typical patterns
localStorageis not shared across subdomains, because it is origin-scopedsessionStorageis also origin-scoped and tab-scoped- cookies can be shared across subdomains if the
Domainattribute is set appropriately
If your app uses app.example.com, auth.example.com, and billing.example.com, verify the exact handoff path.
Things that break in practice
- Login on
auth.example.comsets a cookie only for that host, thenapp.example.comcannot read it - App code expects
localStorageset on one subdomain to be available on another, which it never will be - A redirect returns to the app, but the cookie is marked
SameSite=Strictand is not sent as expected - A logout on one subdomain clears UI state but leaves a valid cookie elsewhere
Example cookie check in Playwright
import { test, expect } from '@playwright/test';
test('cookie is present on the app domain after auth redirect', async ({ page }) => {
await page.goto('https://auth.example.com/login');
// perform login steps here
await page.goto('https://app.example.com/dashboard');
const cookies = await page.context().cookies(‘https://app.example.com’); expect(cookies.some(c => c.name === ‘session_id’)).toBeTruthy(); });
If you are validating cookie scope in a manual or semi-automated workflow, inspect the Domain, Path, Expires, Secure, and SameSite values. These attributes often explain why the app works in one environment and fails in another.
Test session expiration, not just logout
Logout is an explicit action. Session expiration is a timing problem, and timing problems often expose the most expensive bugs.
A session can appear valid on the client while the server has already expired it. The app should not keep trusting client-side storage after the server says no.
What to validate
- The app detects expired authentication cleanly
- Protected API calls fail and trigger the right recovery path
- The user is redirected or shown a session-expired message
- Cached UI state does not leak private data after expiration
- Refresh after expiration does not silently restore access
Useful approach for automation
Instead of waiting for a real production timeout, shorten the session in a test environment and assert the expiration path.
import { test, expect } from '@playwright/test';
test('forces reauthentication after session expiration', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
await page.evaluate(() => { localStorage.setItem(‘auth_token’, ‘expired-test-token’); });
await page.reload(); await expect(page.getByText(/session expired|sign in again/i)).toBeVisible(); });
This example assumes your test environment can inject or simulate an expired token. In real systems, you may need to expire server-side sessions through a test API or a controlled fixture.
Test cookie handoff and redirect flows
Cookie handoff is often where auth bugs become intermittent. A login flow may bounce through an identity provider, set a cookie, then redirect back to the app. If any step has a scope mismatch, you may get a session that works only in one browser, one environment, or one redirect path.
When testing cookie handoff, check these details:
- The cookie is set on the expected domain
- The redirect chain lands on the correct subdomain
- The final app request includes the cookie
- The cookie survives the redirect in the same browser session
- The app handles a missing or delayed cookie gracefully
A quick debugging trick is to inspect the network request after redirect and confirm whether the browser actually sends the expected cookie. That is often more reliable than only checking the document state.
Test for stale client state after storage changes
A subtle bug appears when the app updates storage but fails to re-read it, or re-reads it too late. For example, a logged-out user may still see their name in the header because React state was initialized from storage on page load and never invalidated.
Test these transitions:
- Login, then refresh, then logout
- Logout, then navigate back
- Change account, then open a protected page
- Expire session, then click an old bookmark
You want to catch cases where the UI briefly shows stale private data even though the server already rejected the session.
Example of a back-navigation check
import { test, expect } from '@playwright/test';
test('back button does not restore private data after logout', async ({ page }) => {
await page.goto('https://app.example.com/account');
await page.goto('https://app.example.com/logout');
await page.goBack();
await expect(page).toHaveURL(/login/); await expect(page.getByText(‘Account settings’)).toHaveCount(0); });
This kind of test is especially useful when client-side routing caches pages in memory.
Make tests reliable in CI
Browser storage tests often become flaky when the test environment is not isolated. The main causes are shared state, reused profiles, and uncontrolled timing.
Good practices
- Start each test with a fresh browser context or profile
- Clear storage before and after tests if your harness reuses a session
- Avoid depending on real clock time for expiration checks when a test API can simulate it
- Verify state through both the browser and the backend when possible
- Keep subdomain and origin setup consistent across environments
A GitHub Actions job for browser tests usually looks like a normal Playwright or Selenium run, but the important part is the isolation strategy, not the CI vendor.
name: browser-state-tests
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 test –project=chromium
If these tests are unstable, check whether your app depends on a background refresh, delayed cookie propagation, or a shared test user that other jobs are modifying.
Common failure patterns to watch for
Here are the bugs that show up again and again when teams first start to test browser storage persistence:
-
Assuming refresh equals persistence
sessionStoragesurvives refresh, so this check alone is too weak. -
Assuming storage equals authentication
A token in the browser does not prove the server still accepts it. -
Using the wrong scope in tests
A test that reuses the same context can hide tab isolation bugs. -
Treating subdomains like the same origin
They are not. Storage rules differ. -
Forgetting logout paths
If logout leaves stale storage behind, back navigation and refresh can re-expose private state. -
Ignoring expiration edge cases
Expiration often breaks flows that looked fine under active sessions.
If you only test the happy path, you are mostly testing that the browser can load the page, not that state management is correct.
A minimal checklist you can reuse
Use this as a smoke test for any auth-heavy app:
- Login persists after refresh
localStoragevalues survive same-origin reloadssessionStoragesurvives refresh but not a new tab- Cookies are scoped to the right domain and path
- Subdomain redirects preserve the intended session
- Logout clears or invalidates all relevant state
- Expired sessions force reauthentication
- Back navigation does not reveal private data
- Multiple tabs do not share temporary state accidentally
When to use tools versus custom code
If your team already has Playwright, Cypress, or Selenium coverage, browser storage checks are usually best added directly to those suites. That keeps the checks close to the user flows they validate.
For teams that want repeatable cross-page state checks without building a lot of harness code, a tool like Endtest can help, especially when you want agentic AI test creation with editable steps and straightforward state assertions across cookies, variables, and page transitions. It is not a replacement for understanding storage rules, but it can reduce the friction of turning those rules into consistent checks.
Final thoughts
To test browser storage persistence well, you need more than a quick read of localStorage. You need to prove that the app behaves correctly across refreshes, tabs, subdomains, and session expiration, and that the browser state matches the server state at each step.
The most valuable tests are the ones that model real user transitions:
- signing in, then refreshing
- moving from auth to app subdomains
- opening a second tab
- waiting for a session to expire
- logging out and trying to return
If you build those cases into your CI workflow, you will catch the kinds of bugs that users notice immediately, but that are easy to miss in ordinary UI checks. That is the difference between a suite that only confirms pages load and a suite that actually protects login persistence, session integrity, and cross-domain state management.