Incognito Mode Detection
How sites infer a private window, why every method has a shelf life, and what three generations of dead Chromium techniques teach about the ones that work.
Published · Updated
- Category
- Environment
- API
navigator.storage.getDirectory- Reveals
- private-window state
- Spoofing
- No practical spoof
- Since
- 2017
Private-mode detection recovers a single bit — private window or not — and it is the most volatile bit in this dictionary. Browser vendors treat detectability as a bug and close each technique as it becomes known, so any given method has a shelf life measured in browser releases rather than years.
The panel above ran whichever method currently applies to your engine.
Identifying the engine without the user agent
Every method here is engine-specific, so detection starts by identifying the engine — and the user agent is the wrong tool, since it is the one thing every spoofer changes (the gzip OS byte and Client Hints entries make the same point).
A sturdier trick: ask each engine to produce an error and measure the length of the message it writes.
function engineId() {
try {
const neg = parseInt("-1");
neg.toFixed(neg); // RangeError: digits out of range
} catch (e) {
return e.message.length;
}
}toFixed(-1) is out of range everywhere, but each engine phrases the
complaint differently. The message length comes out at 51 on V8, 25 on
SpiderMonkey, and 43 or 44 on JavaScriptCore. No UA override touches it,
because it is a property of the error-message string baked into the engine.
Detecting incognito in Chromium: three generations of method
The Chromium lineage is the clearest illustration of why this bit is hard to hold onto. Three techniques, each retired for a different reason.
Generation 1 — the storage quota heuristic. Incognito profiles were
memory-backed and reported a much smaller navigator.storage.estimate() quota
than a disk-backed profile. The refinement was to scale the threshold by
performance.memory.jsHeapSizeLimit so it tracked device class instead of
hard-coding a byte cutoff. Dead: Chrome changed how incognito quota is
provisioned, and the signal went away.
Generation 2 — OPFS flush timing. Time a flush through the Origin Private File System; memory-backed storage returns faster than disk. Dead: it relied on an absolute time threshold, and the hardware spread on real Android devices was wider than the gap between incognito and normal. Slow phones false-positived.
Generation 3 — IndexedDB durability ratio. The one running above. IndexedDB transactions accept a durability hint:
db.transaction("s", "readwrite", { durability: "strict" }) // fsync
db.transaction("s", "readwrite", { durability: "relaxed" }) // no fsyncIn a normal profile, strict genuinely fsyncs to disk and costs measurably
more than relaxed. In incognito, IndexedDB is backed by an in-memory LevelDB
environment, so the fsync is a no-op and both cost the same. Time a block of
each and take the ratio:
- ratio ≈ 1.0 → the writes never reached a disk → incognito
- ratio > 1.3 → something is being flushed → normal window
The reason this generation has survived where generation 2 did not is that a ratio is self-normalising. A slow phone is slow in both the numerator and the denominator, so device speed cancels out. That is the transferable lesson: prefer a measurement that compares a device against itself over one that compares it against a constant you chose.
The panel above reports your actual ratio, not just the verdict, so you can see how much margin the threshold has on your machine.
Safari and Firefox: reading the rejection
Both engines refuse the Origin Private File System in a private window, and both throw — but the useful part is which error they throw, because the message distinguishes a private window from every other failure.
try {
await navigator.storage.getDirectory();
// resolved: not a private window
} catch (e) {
// Safari: "unknown transient reason"
// Firefox: "Security error"
}Matching the message rather than merely catching the throw is what keeps this from false-positiving on unrelated storage failures.
Older versions need older methods, and the shape of the ladder is worth seeing.
Safari 13–18 predates OPFS: there, opening an IndexedDB store and trying to
put(new Blob()) fails in a private window with "are not yet supported". Older
Safari still leaked more loudly — openDatabase threw, and localStorage.setItem
threw a quota error. Firefox before OPFS is detected by indexedDB.open()
failing specifically with InvalidStateError.
That InvalidStateError check illustrates a discipline worth copying. Only a
genuine private window produces that specific error name. A quota problem, a
corrupt profile, dom.indexedDB.enabled=false, or an enterprise policy all
produce different failures — and the correct response to those is not
private, not "detection failed, assume private". Failing open on ambiguous
evidence is why a detector does not misfire on the unusual-but-ordinary user.
Why this keeps breaking
Every method above leans on an implementation detail, never on a specification. There is no standards-blessed way to read this bit, and vendors intend it to stay that way. When Chrome changed incognito quota provisioning, every quota-based detector broke overnight. When Firefox and Safari shipped OPFS, both a new method and a new set of error strings arrived at once.
So the honest headline is that any private-mode detector you deploy is a maintenance commitment, not an integration. The reference implementation is detectIncognito, which I maintain; the version history reads as a list of methods that stopped working.
The signals are fingerprinting surface anyway
Set the private-mode question aside and the same measurements are useful on their own. The strict-versus-relaxed ratio is a storage-performance characteristic. Whether OPFS is available partitions the browser population. The error-message length that identifies the engine is a stable engine identifier that survives every user-agent override. The private-mode verdict is one consumer of these; the raw readings feed a fingerprint regardless.
Notes on the probe above
It mirrors detectIncognito v1.9.0 with one deliberate difference: the library runs up to 15 rounds with a ~1 second budget, and the probe here runs up to 9 with a ~700 ms budget so the page stays responsive. Fewer rounds means a noisier median, so treat a ratio close to the 1.3 threshold as less certain here than the library would be.
Two limits worth stating plainly. This is a heuristic, not a platform-supported query — on an engine where the durability hint is not honoured, the probe abstains rather than guessing. And private browsing is not suspicious behaviour, unlike the automation signals: it is a feature people use for ordinary reasons, and the correct product response to detecting it is almost never to block someone.
References
- detectIncognito — cross-browser implementation, MIT licensed.
- MDN:
StorageManager.getDirectory(),IDBTransaction.durability.
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Private-mode detection, using the methods that currently work rather than
// the ones that used to. Mirrors detectIncognito v1.9.0 (MIT, Joe Rutkowski),
// with the Chromium timing test shortened so it doesn't stall the page — see
// the entry text for what that trades away.
// Engine identification without touching the user agent. Each engine builds
// the RangeError message for (-1).toFixed(-1) differently, and the *length* of
// that message is a stable engine tell that no UA override reaches.
function engineId() {
try {
const neg = parseInt("-1");
neg.toFixed(neg);
} catch (e) {
return e.message.length;
}
return 0;
}
function engineName(id) {
if (id === 44 || id === 43) return "safari";
if (id === 51) return "chromium";
if (id === 25) return "firefox";
return "unknown";
}
// Chromium: incognito IndexedDB is backed by an in-memory LevelDB env, so a
// durability:"strict" commit's fsync is a no-op and costs the same as
// "relaxed". On disk, strict actually fsyncs and costs more. The ratio is
// self-normalising, which is why it survives the hardware spread that killed
// the previous absolute-threshold approach.
const ROUNDS = 9;
const MIN_ROUNDS = 5;
const CAP_MS = 700;
const WRITES = 12;
const THRESHOLD = 1.3;
async function chromiumRatio() {
const dbName = "__di_" + Math.random().toString(36).slice(2);
const payload = new Uint8Array(16384);
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, 1);
req.onupgradeneeded = () => req.result.createObjectStore("s");
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const cleanup = () => {
db.close();
indexedDB.deleteDatabase(dbName);
};
// If the engine ignores the durability hint the whole comparison is a no-op.
let honored = false;
try {
const t = db.transaction("s", "readwrite", { durability: "strict" });
honored = t.durability === "strict";
t.abort();
} catch {
/* option unsupported on this engine */
}
if (!honored) {
cleanup();
return { ratio: null, reason: "durability hint not honored" };
}
const block = (durability) =>
new Promise((resolve, reject) => {
const t0 = performance.now();
let i = 0;
const step = () => {
if (i === WRITES) return resolve(performance.now() - t0);
const tx = db.transaction("s", "readwrite", { durability });
tx.objectStore("s").put(payload, i);
i++;
tx.oncomplete = step;
tx.onerror = tx.onabort = () => reject(tx.error);
};
step();
});
try {
const start = performance.now();
await block("relaxed");
await block("strict"); // warm-up, discarded
const ratios = [];
for (let r = 0; r < ROUNDS; r++) {
const rel = await block("relaxed");
const str = await block("strict");
ratios.push(rel > 0 ? str / rel : Infinity);
if (ratios.length >= MIN_ROUNDS && performance.now() - start >= CAP_MS) break;
}
ratios.sort((a, b) => a - b);
return { ratio: ratios[ratios.length >> 1], rounds: ratios.length };
} finally {
cleanup();
}
}
// Safari and Firefox both refuse the Origin Private File System in a private
// window, but with different messages. Matching the message is the method.
async function opfsRejection() {
try {
await navigator.storage.getDirectory();
return { ok: true, message: null };
} catch (e) {
return { ok: false, message: e && e.message ? e.message : String(e) };
}
}
export async function probe() {
const id = engineId();
const engine = engineName(id);
const rows = {
"Engine (from (-1).toFixed(-1) message length)": `${engine} (${id})`,
};
let verdict = null;
let method = "none applicable for this engine";
if (engine === "chromium") {
method = "IndexedDB strict-vs-relaxed durability timing";
const r = await chromiumRatio();
if (r.ratio === null) {
rows["Result"] = r.reason;
} else {
rows["fsync cost ratio (strict / relaxed)"] = r.ratio.toFixed(3);
rows["Rounds measured"] = r.rounds;
rows["Threshold"] = `< ${THRESHOLD} means writes never reach a disk`;
verdict = r.ratio < THRESHOLD;
}
} else if (engine === "safari" || engine === "firefox") {
method = "OPFS (navigator.storage.getDirectory) rejection message";
if (typeof navigator.storage?.getDirectory !== "function") {
rows["Result"] = "OPFS unavailable — this engine predates the current method";
} else {
const r = await opfsRejection();
const needle = engine === "safari" ? "unknown transient reason" : "Security error";
rows["OPFS getDirectory()"] = r.ok ? "resolved" : "rejected";
if (!r.ok) rows["Rejection message"] = r.message;
rows["Looking for"] = `"${needle}"`;
verdict = r.ok ? false : r.message.includes(needle);
}
}
rows["Method"] = method;
rows["Verdict"] =
verdict === null ? "undetermined" : verdict ? "likely private" : "likely a normal window";
return {
// Timing-derived, so it is reported but kept out of the digest: near the
// threshold this verdict can differ between two reloads on one machine,
// and a fingerprint that moves on reload is not a fingerprint.
volatile: { "Window": verdict === null ? "undetermined" : verdict ? "private" : "ordinary" },
value: rows,
label: verdict === null ? "undetermined" : verdict ? "likely private" : "likely not private",
};
}