Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

39 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

is-incognito-mode

Detect private / incognito browsing in 4 lines of code.

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.



30-second tour

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.


Why you'd use this

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.

Install

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>

See it run

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).


How it decides (under the hood)

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])
Loading

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).


Usage

Boolean verdict

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

const inPrivate = await isIncognito();

Rich detection result

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.

Timeouts & cancellation

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.

Caching the verdict

Private / incognito state can't change within a page load, so re-running the storage probes on every call is wasted work. Pass cache: true to memoize the first successful verdict and return it instantly thereafter:

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

await detectIncognito({ cache: true }); // probes once
await detectIncognito({ cache: true }); // instant β€” same cached result
  • Scope. The cache is keyed by the live navigator object, so it lives exactly as long as the page (per-document, per-origin). Nothing is stored globally β€” injecting a fresh globals (as tests and per-request SSR do) gives a fresh, isolated cache with no teardown to remember.
  • Failures aren't cached. A TIMEOUT, ABORTED, or PROBE_FAILED rejection is never stored, so the next call retries cleanly.
  • It caches the verdict, not the inputs. A cache hit ignores a later call's differing privateQuotaThresholdBytes. If you vary the threshold per call, leave cache off.

Off by default β€” existing behaviour is unchanged.

Tuning the detection

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.

Injecting globals (for testing)

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)

Error handling

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;
    }
  }
}

CommonJS

const { isIncognito } = require('is-incognito-mode');

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

API at a glance

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/


Compatibility

Browsers

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 β€”

Node / runtimes

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.

Bundlers & frameworks

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.


What's new in v2

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.


Comparison with alternatives

  • 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.

Contributing

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

Security

Report vulnerabilities privately per SECURITY.md.

License

MIT Β© Aliaksandr Yankouski

Releases

Packages

Used by

Contributors

Languages