is-incognito-mode - v2.3.0
    Preparing search index...

    is-incognito-mode - v2.3.0

    is-incognito-mode

    Zero dependencies — fully typed — dual ESM + CJS — timeout-safe & cancelable — ~2 kB min+gzip.

    npm version npm downloads CI Coverage Bundle size Types Docs License


    Open it in a normal window. Then re-open it in private/incognito mode. Watch the verdict flip.



    npm install is-incognito-mode
    
    import { isIncognito } from 'is-incognito-mode';

    if (await isIncognito()) {
    showPaywall();
    } else {
    trackVisit();
    }

    That's it. One async call, one boolean. Works on Chrome, Firefox, Safari, Edge, and (best-effort) the long tail of older WebKit shells.

    Need more than a yes/no? Use detectIncognito() for a typed object with browser, confidence, quota, and strategy fields.


    Browsers don't expose a "private mode" API on purpose — but private windows still leak the fact through resource limits and storage shape. is-incognito-mode packages the current state-of-the-art per-engine detection as a tiny, typed, zero-dep module, so you can stop hand-rolling heuristics that browsers patched out years ago.

    A few real-world fits:

    Scenario What you do
    Soft paywall Discourage incognito bypass without hard-blocking the user.
    Respectful analytics Skip beacon calls in private sessions to honor the signal.
    Long forms / surveys Warn before storing state that will vanish on close.
    Fraud / abuse signals One input among many — never the sole decider.
    E2E test conditioning Branch tests based on whether you're driving a private tab.

    pnpm add is-incognito-mode      # or  npm i is-incognito-mode
    # or yarn add is-incognito-mode
    # or bun add is-incognito-mode

    No-install — straight from a CDN:

    <script type="module">
    import { isIncognito } from 'https://esm.sh/is-incognito-mode@2';
    console.log(await isIncognito());
    </script>

    A ready-to-run demo page is hosted alongside the docs:

    https://yankouskia.github.io/is-incognito-mode/demo/

    Open it once in a regular window, then once in incognito/private — the verdict, browser, confidence, quota, and strategy update live. Source: examples/browser/index.html (single static file, no build step).


    There is no single cross-browser signal — each engine leaks private mode in a different place — so the library picks the right probe per engine.

    Detection flow diagram
    flowchart TD
    A[detectIncognito] --> D{which engine?}
    D -- Chromium --> C1["navigator.storage.estimate()"]
    C1 --> C2{"headroom (quota − usage) &lt; 9.5 GiB?"}
    C2 -- yes --> R1([private — high confidence])
    C2 -- no --> R2([normal — high confidence])
    D -- Firefox --> F1["navigator.storage.getDirectory() — OPFS"]
    F1 --> F2{rejected with a security error?}
    F2 -- yes --> R3([private — high confidence])
    F2 -- no --> R4([normal — high confidence])
    D -- Safari/WebKit --> S1["navigator.storage.getDirectory() — OPFS"]
    S1 --> S2{rejected 'unknown transient reason'?}
    S2 -- yes --> R5([private])
    S2 -- no --> R6([normal])
    D -- Edge legacy / IE --> G[PointerEvent + indexedDB heuristic]
    D -- unknown --> X([throw UNSUPPORTED_BROWSER])

    Chromium (Chrome, Edge, Brave, Opera, …). Chrome's predictable-reported-quota mitigation (default since Chromium 147) was meant to mask incognito by reporting a fixed storage quota — but it didn't fully equalize the two modes. Empirically, navigator.storage.estimate() reports quota = usage + 10 GiB in a normal tab and usage + 9 GiB in an incognito tab. The library looks at the headroom (quota − usage): subtracting usage cancels real consumption and leaves just that offset — a stable 10 GiB vs 9 GiB. Below 9.5 GiB → incognito. (This also catches pre-147 Chromium, whose incognito quota was a small dynamic value.)

    Firefox & Safari. The Origin Private File System (navigator.storage.getDirectory()) is rejected in private mode — Firefox throws a security error, Safari throws "unknown transient reason". A clean resolve means a normal window.

    Legacy Edge / IE. No indexedDB while PointerEvent exists → private.

    If you need to override the 9.5 GiB Chromium cutoff, pass privateQuotaThresholdBytes (see Tuning).


    import { isIncognito } from 'is-incognito-mode';

    const inPrivate = await isIncognito();
    import { detectIncognito } from 'is-incognito-mode';

    const { isPrivate, browser, confidence, quota, strategy } =
    await detectIncognito();

    console.log(
    `${browser} (${confidence}) — strategy: ${strategy}, quota: ${quota}`,
    );
    // → "chromium (high) — strategy: chromium-quota, quota: 9663676416"

    Fields on DetectionResult:

    field type notes
    isPrivate boolean Final verdict.
    browser BrowserName Coarse engine: chromium, firefox, safari, webkit, edge-legacy, ie, unknown.
    confidence 'high' | 'medium' | 'low' high for the primary per-engine probe; low for legacy heuristics.
    quota number | null estimate().quota in bytes (Chromium); null for the OPFS and legacy strategies.
    strategy DetectionStrategyName Which probe produced the verdict — see How it decides.

    Detection is a Promise, and in rare browser states a storage probe can stall — for example a Firefox indexedDB.open request that never fires success or error. On a critical render path (paywall, analytics gate) that risks freezing your code, so you can put a deadline on the call:

    import { detectIncognito } from 'is-incognito-mode';

    // Rejects with an IncognitoDetectionError of code 'TIMEOUT' if no verdict
    // arrives within 5 seconds. Recommended on any hot path.
    const result = await detectIncognito({ timeoutMs: 5000 });

    Pass an AbortSignal to cancel detection — e.g. tied to a component lifecycle so a verdict that arrives after the user has navigated away is discarded:

    import { detectIncognito, IncognitoDetectionError } from 'is-incognito-mode';

    const controller = new AbortController();
    // React: useEffect(() => () => controller.abort(), []);

    try {
    await detectIncognito({ signal: controller.signal });
    } catch (error) {
    if (error instanceof IncognitoDetectionError && error.code === 'ABORTED') {
    // expected — the caller cancelled. Ignore.
    }
    }

    Both options compose; whichever fires first wins (TIMEOUT vs ABORTED). When a bound trips, the in-flight probe is abandoned and its listeners detached. timeoutMs defaults to undefined (no deadline), so existing callers are unaffected — but enabling it is the safe default for production.

    You normally do not need to configure anything. The Chromium strategy compares the storage headroom (estimate().quota − estimate().usage) to a 9.5 GiB cutoff — the midpoint between the 10 GiB Chrome reports for a normal tab and the 9 GiB it reports for an incognito tab. To override that cutoff:

    import { detectIncognito } from 'is-incognito-mode';

    const result = await detectIncognito({
    privateQuotaThresholdBytes: 9.5 * 1024 * 1024 * 1024,
    });

    DEFAULT_PRIVATE_QUOTA_BYTES (9.5 GiB) is exported as the reference value.

    detectIncognito accepts a globals override so unit tests don't have to monkey-patch navigator or window:

    import { detectIncognito } from 'is-incognito-mode';

    const result = await detectIncognito({
    globals: {
    navigator: {
    userAgent: 'Mozilla/5.0 ... Chrome/148.0',
    // Chrome reports quota = usage + 9 GiB for an incognito tab.
    storage: {
    estimate: () => Promise.resolve({ quota: 9 * 1024 ** 3, usage: 0 }),
    },
    },
    window: {},
    },
    });
    // result.isPrivate === true (9 GiB headroom < 9.5 GiB)
    import { isIncognito, IncognitoDetectionError } from 'is-incognito-mode';

    try {
    const incognito = await isIncognito();
    // ...
    } catch (error) {
    if (error instanceof IncognitoDetectionError) {
    switch (error.code) {
    case 'NOT_A_BROWSER':
    // Server-side render path
    break;
    case 'UNSUPPORTED_BROWSER':
    // Probably a bot / curl / node-fetch
    break;
    case 'PROBE_FAILED':
    // The engine's probe could not produce a verdict
    break;
    case 'TIMEOUT':
    // Detection exceeded the `timeoutMs` deadline
    break;
    case 'ABORTED':
    // Detection was cancelled via the `signal` option
    break;
    }
    }
    }
    const { isIncognito } = require('is-incognito-mode');

    // Default-import-style:
    const detect = require('is-incognito-mode').default;

    Export Kind Description
    isIncognito(options?) function Resolves to boolean.
    detectIncognito(options?) function Resolves to a rich DetectionResult.
    IncognitoDetectionError class Typed error with code: 'NOT_A_BROWSER' | 'UNSUPPORTED_BROWSER' | 'PROBE_FAILED' | 'TIMEOUT' | 'ABORTED'.
    DEFAULT_PRIVATE_QUOTA_BYTES const Chromium headroom cutoff (9.5 GiB).
    BrowserName (type) type Coarse engine name.
    DetectionResult (type) type Rich result shape — see "Usage".
    DetectionConfidence (type) type 'high' | 'medium' | 'low'.
    DetectionStrategyName (type) type Strategy identifier.
    DetectIncognitoOptions (type) type Options bag.

    Full generated reference: https://yankouskia.github.io/is-incognito-mode/


    Engine Detection strategy Confidence
    Chromium (incl. 147+) storage.estimate() headroom (quota−usage) high
    Firefox ≥ 111 OPFS navigator.storage.getDirectory() high
    Safari ≥ 15.2 OPFS navigator.storage.getDirectory() high
    Older Safari / WebKit localStorage + openDatabase probes medium-low
    Older Firefox indexedDB.open error path low
    Edge (legacy) PointerEvent + indexedDB heuristic low
    IE 10–11 PointerEvent + indexedDB heuristic low
    All others (unknown) throws UNSUPPORTED_BROWSER

    Not supported at runtime — this is a browser-only package and will throw NOT_A_BROWSER if invoked without a navigator. The package builds on Node ≥ 20.

    Ships ESM and CJS with proper exports map and .d.ts / .d.cts. Works out-of-the-box in Vite, Next.js (client components), Remix, Astro, Webpack, Rollup, esbuild, Bun, and Deno.


    v1.x v2.0
    Detection technique FileSystem API + IndexedDB + localStorage + PointerEvent per-engine probes: Chromium quota headroom, Firefox/Safari OPFS
    TypeScript shipped JS only strict TypeScript source, full .d.ts
    Module formats UMD + CJS ESM + CJS dual publish
    Dependencies get-browser zero
    Bundle size ~3 kB min+gzip ~2 kB min+gzip (≈1.2 kB brotli)
    Engines Node ≥ 8 Node ≥ 20
    Error model throw 'string' IncognitoDetectionError with code

    See BREAKING_CHANGES.md for migration recipes and DECISIONS.md for the reasoning behind each big call.


    • detectincognitojs — excellent, similar in spirit. Pick that if you want a UMD bundle or a richer per-browser breakdown.
    • Inline UA sniff + try/catch around localStorage — broken in every modern browser. Don't.
    • Just check window.webkitRequestFileSystem — patched out of Chrome 76. Don't.

    Pull requests welcome. See CONTRIBUTING.md for the dev loop, conventional commits, and the changeset workflow. Be excellent — the Contributor Covenant 2.1 applies.

    Report vulnerabilities privately per SECURITY.md.

    MIT © Aliaksandr Yankouski