npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

is-incognito-mode

v2.3.0

Published

Reliably detect whether the user's browser is in private / incognito mode. Zero dependencies, dual ESM + CJS, fully typed.

Downloads

850

Readme

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

Try the live demo →

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.

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


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.

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