Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Safari Private Browsing Storage Isolation Test

Run Playwright's WebKit project on a Figment Mac and confirm Safari Private Browsing drops localStorage, IndexedDB, cookies and sessionStorage when the context closes. A Figment M-series Mac from $95/month is the recommended target because Safari-fidelity codecs and layout match real user devices on Apple silicon; the $29 virtual Intel macOS plan (2 vCPU, 8 GiB RAM, 80 GiB disk) also runs Playwright's WebKit build and is useful for pure storage-logic checks where codec fidelity is not the point. Start a configuration at Figment Mac hosting.

The suite below is small on purpose: it writes known values to each store inside a fresh WebKit BrowserContext, confirms the writes land, closes the context, and then opens a new context to assert nothing leaked across the boundary.

What Safari's Private Browsing Actually Isolates

WebKit documents Safari's Private Browsing as a per-tab ephemeral session: "Safari's Private Browsing Mode uses a new ephemeral session for each tab the user opens to isolate tabs from each other" (webkit.org/tracking-prevention). Cookies, localStorage, sessionStorage, IndexedDB and cache for that session go away when the tab closes.

Two consequences matter for tests:

  • A Private Browsing tab does not see data written in a previous normal-browsing session, so any first-paint code that assumes a prior localStorage key is wrong in Private Browsing.
  • Two Private Browsing tabs in Safari do not share storage with each other, which differs from Chromium's Incognito, where all Incognito tabs in one window share a profile.
BehaviourWebKit Private BrowsingChromium Incognito
Storage persists after tab/context closeNoNo
Two private tabs share storage in one sessionNoYes
localStorage / IndexedDB writes succeed during the sessionYesYes

Playwright models each BrowserContext as an independent incognito-like profile with its own cookies, localStorage, IndexedDB, cache and service workers (playwright.dev BrowserContext), which is the right primitive for simulating a Private Browsing tab.

Writing localStorage and IndexedDB in a WebKit Context

Pin the test to the webkit project so results reflect the same engine Safari ships. Helpers below write and read both stores from the page runtime.

// storage-isolation.spec.ts
import { test, expect, BrowserContext, Page } from '@playwright/test';

const TEST_ORIGIN = process.env.BASE_URL ?? 'http://localhost:3000';
const LS_KEY = 'privacyTest_ls';
const LS_VALUE = 'isolation-check-value';
const IDB_DB = 'privacyTestDB';
const IDB_STORE = 'entries';
const IDB_KEY = 'privacyTest_idb';
const IDB_VALUE = 'idb-isolation-check';

async function writeStorage(page: Page): Promise<void> {
  await page.evaluate(
    ({ lsKey, lsVal, dbName, storeName, idbKey, idbVal }) => {
      localStorage.setItem(lsKey, lsVal);
      return new Promise<void>((resolve, reject) => {
        const req = indexedDB.open(dbName, 1);
        req.onupgradeneeded = () => {
          req.result.createObjectStore(storeName);
        };
        req.onsuccess = () => {
          const tx = req.result.transaction(storeName, 'readwrite');
          tx.objectStore(storeName).put(idbVal, idbKey);
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        };
        req.onerror = () => reject(req.error);
      });
    },
    { lsKey: LS_KEY, lsVal: LS_VALUE,
      dbName: IDB_DB, storeName: IDB_STORE,
      idbKey: IDB_KEY, idbVal: IDB_VALUE }
  );
}

async function readLocalStorage(page: Page, key: string): Promise<string | null> {
  return page.evaluate((k) => localStorage.getItem(k), key);
}

async function readIndexedDB(page: Page): Promise<unknown> {
  return page.evaluate(
    ({ dbName, storeName, idbKey }) =>
      new Promise((resolve) => {
        const req = indexedDB.open(dbName);
        req.onsuccess = () => {
          const db = req.result;
          if (!db.objectStoreNames.contains(storeName)) {
            resolve(null);
            return;
          }
          const tx = db.transaction(storeName, 'readonly');
          const getReq = tx.objectStore(storeName).get(idbKey);
          getReq.onsuccess = () => resolve(getReq.result ?? null);
          getReq.onerror = () => resolve(null);
        };
        req.onerror = () => resolve(null);
      }),
    { dbName: IDB_DB, storeName: IDB_STORE, idbKey: IDB_KEY }
  );
}

Asserting Zero Residual Entries After Context Disposal

The suite opens a write context, confirms writes land in that same context, closes it, then opens a fresh context for each assertion. A non-null read from the fresh context would indicate a genuine cross-context leak.

test.describe('Safari Private Browsing storage isolation', () => {
  let writeContext: BrowserContext;

  test.beforeAll(async ({ browser }) => {
    writeContext = await browser.newContext();
    const page = await writeContext.newPage();
    await page.goto(TEST_ORIGIN);

    await writeStorage(page);

    const lsValue = await readLocalStorage(page, LS_KEY);
    const idbValue = await readIndexedDB(page);
    expect(lsValue).toBe(LS_VALUE);
    expect(idbValue).toBe(IDB_VALUE);

    const state = await writeContext.storageState({ indexedDB: true });
    await test.info().attach('storage-state-before-close.json', {
      body: Buffer.from(JSON.stringify(state, null, 2)),
      contentType: 'application/json',
    });

    await writeContext.close();
  });

  test('localStorage is empty after context disposal', async ({ browser }) => {
    const freshContext = await browser.newContext();
    const page = await freshContext.newPage();
    await page.goto(TEST_ORIGIN);
    expect(await readLocalStorage(page, LS_KEY)).toBeNull();
    await freshContext.close();
  });

  test('IndexedDB has no residual entries after context disposal', async ({ browser }) => {
    const freshContext = await browser.newContext();
    const page = await freshContext.newPage();
    await page.goto(TEST_ORIGIN);
    expect(await readIndexedDB(page)).toBeNull();
    await freshContext.close();
  });

  test('sessionStorage is not accessible from a new context', async ({ browser }) => {
    const writeCtx = await browser.newContext();
    const writePage = await writeCtx.newPage();
    await writePage.goto(TEST_ORIGIN);
    await writePage.evaluate(() =>
      sessionStorage.setItem('privacyTest_ss', 'ss-isolation-value')
    );
    await writeCtx.close();

    const readCtx = await browser.newContext();
    const readPage = await readCtx.newPage();
    await readPage.goto(TEST_ORIGIN);
    const residual = await readPage.evaluate(() =>
      sessionStorage.getItem('privacyTest_ss')
    );
    expect(residual).toBeNull();
    await readCtx.close();
  });
});

storageState() serializes cookies and localStorage by default; pass { indexedDB: true } to include IndexedDB in the JSON snapshot, per the Playwright API reference linked above. sessionStorage is per-tab and intentionally excluded from storageState(), so the dedicated test above exercises that path directly.

Pinning the Run to WebKit on macOS

Lock the project list so results reflect the macOS WebKit build, and keep a Chromium project alongside for parity evidence:

// playwright.config.ts (excerpt)
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Playwright's own browser documentation notes that media codec availability "varies substantially between Linux, macOS and Windows" and that "for the closest-to-Safari experience you should run WebKit on mac" (playwright.dev/docs/browsers). For storage-only assertions, Linux WebKit is usually adequate; for anything that touches media or layout surfaces that differ by platform, run on macOS.

Which Figment Mac Fits This Job

  • Virtual Intel macOS, $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk): enough for a single-project WebKit storage suite, pure JS logic, and small CI workloads. No GPU acceleration, no audio, and Apple silicon-only tooling will not run. Compatible Intel toolchains, including supported Xcode 26-or-earlier, run here when the job also needs native build steps.
  • M-series Mac from $95/month: the right target when you want Safari-fidelity codecs, GPU-accelerated layout, or ARM-native tooling in the same job. Exact chip, RAM and disk are confirmed in the quote; physical M-series stock is request-based, so share a start date when you ask.
  • Managed Build Mac, $249/month: one M-series Mac plus CI setup, backups, monitoring and 30 minutes of monthly support. Useful if you want the pipeline above wired into your runner without taking on the ops.
  • Managed Mac Team, $999/month: two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support.

Request a configuration at get started. Tax and any fees apply as stated in the actual offer you receive.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms