Anti-Bot / Experiment

ST—005

Can IndexedDB Timing Reveal an Incognito Browser?

An independent test of private-mode inference: 240 Chrome experiments, strict versus relaxed IndexedDB writes, and a RAM-backed profile that breaks the assumption.

An isolated browser context and a normal RAM-backed profile lead to similar timing observations, followed by a reminder to test the inference. Conceptual illustration, not captured measurements.

Research context

Test date

A page runs a few database writes and reports that your browser might be in Incognito mode. You did not grant a special permission or send it a screenshot of your window. It inferred something from the time those writes took.

In our manual Chrome tests, the distinction looked convincing. Normal browsing produced a strict-to-relaxed write-time ratio of 14.24. Incognito produced 1.00.

Then we tested a normal, persistent Chrome profile on a RAM-backed filesystem. It produced private-like timings too, even though its stored state survived a browser restart.

The timing difference was real. The interpretation needed another control.

This investigation builds on Joe Rutkowski’s open-source detectIncognito technique. We examined its Chromium measurement, adapted it for inspection, and tested where the inference holds and where it fails. The technique is prior work. The controlled comparisons and counterexample are our contribution.

What we found

  • Disk-backed profiles separated clearly on our test host. Normal/default contexts stayed above the method’s 1.30 threshold. Isolated browser contexts stayed below it, in both headed and headless Chrome.
  • A persistent profile could still look private. On a RAM-backed filesystem, all 40 normal-context runs in the main comparison fell below the threshold. Changing measurement order did not remove that result.
  • A storage signal is not a bot verdict. Neither a low ratio nor an actual private window establishes automation. Our results cover one Linux host and one Chrome build.

The evidence and source package includes the recorded timings, measurement code, source snapshots, and reproduction instructions. You can also run the interactive check in your own browser.

What the page actually measures

IndexedDB is a browser database API. When creating a write transaction, a page can request different durability behavior:

const relaxed = db.transaction("samples", "readwrite", {
  durability: "relaxed"
});

const strict = db.transaction("samples", "readwrite", {
  durability: "strict"
});

These are alternative transaction examples, not the timed experiment itself. The IndexedDB specification distinguishes the persistence guarantees associated with these settings. A strict transaction asks for stronger protection against losing committed data. Relaxed durability allows completion with weaker persistence guarantees.

That can change transaction completion time. A page can measure the difference without inspecting the browser’s database files.

The Chromium branch of the upstream implementation we inspected compares two timed blocks. Each block performs fifteen sequential write transactions with a 16 KiB payload, advancing only after each transaction completes. One block requests relaxed durability, the other strict durability.

Round ratio = strict block duration / relaxed block duration

Much greater than 1 → strict completion took longer
Close to 1         → little separation between the two settings

After warming up both settings, the method repeats the comparison and takes the median of the per-round ratios. Its threshold is below 1.30 for a private classification. Exactly 1.30 belongs on the other side of that decision.

This is a timing heuristic, not a browser-provided privacy flag. It measures completion latency, including scheduling, serialization, communication between browser components, and storage work. It does not tell JavaScript which physical disk operations occurred.

The original explanation associates private contexts with an in-memory database path, where the cost difference between durability settings can shrink. That is a useful hypothesis to investigate. It does not make every near-one ratio proof of Incognito.

First, the ordinary browser test

We ran a short, fixed-seven-round version of the experiment in manually identified normal and Incognito Chrome windows. The console outputs were:

Normal window:    Probably normal | Ratio: 14.24
Incognito window: Possibly private | Ratio: 1.00

These are two reported console observations, not averages across the later automated study. The wording in that short script is part of its output. A result above the threshold does not independently verify a normal window.

We also ran five experiments per window through our Checks page. The normal window’s aggregate ratio was 17.838, while the Incognito window’s aggregate ratio was 1.000.

Five completed runs in the user-identified normal Chrome window, showing aggregate strict-to-relaxed timing ratio 17.838 and normal-like classifications.
Normal window, identified by the author. Five separate website runs, with aggregate ratio 17.838. These are not the console run that produced 14.24.
Five completed runs in the user-identified Incognito Chrome window, showing aggregate strict-to-relaxed timing ratio 1.000 and private-like classifications.
Incognito window, identified by the author. Five website runs, with aggregate ratio 1.000. The supplied captures show the test interface rather than browser controls, and raw exports for these manual runs were not retained.

Both captures report Linux and Chrome 154 through the user-agent string. That reduced string does not establish a full browser build. The controlled runs below record the executable’s full version separately.

At this point, the easy conclusion would have been that we had reproduced private-mode detection. The more useful question was whether another environment could produce the same signal without being private.

Methodology

The controlled study was recorded on 27 October 2026, using Chrome 154.0.8037.57 and Puppeteer Core 24.43.1 on one Linux x64 host. The machine had an AMD Ryzen AI 9 HX PRO 370 processor, 24 logical CPUs, and approximately 32.8 GB RAM.

We compared three dimensions:

Dimension Conditions Why it matters
Profile filesystem Disk-backed btrfs or RAM-backed tmpfs Tests whether the signal follows storage conditions as well as context mode
Browser context Persistent default or isolated nondefault Compares retained profile state with a separate browser context
Browser presentation Headed or headless Checks whether the observed separation persists with either presentation

The nondefault contexts were created through Puppeteer’s createBrowserContext(). They are our isolated, incognito-like comparison, not additional manually opened Incognito windows. Those two forms of evidence remain separate.

Before measurements in each headed/headless setup, we wrote a local-storage marker in the default context, closed Chrome, and relaunched it with the same profile. The marker remained. The separate context could not read it. This checked retained default-context state and isolation. It was not a separate IndexedDB persistence test.

There were four context/presentation combinations per filesystem. Each received 20 relaxed-first runs and 10 alternating-order sensitivity runs. That gives 160 main runs plus 80 sensitivity runs, or 240 controlled runs. Each run contains repeated timed rounds, rather than one database transaction. Runs within a process are correlated, so 240 runs do not mean 240 independent browsers or machines.

Our instrumented implementation retained the upstream payload, sequential writes, warmups, threshold, and per-run median convention. It also recorded individual timings, recognized both durability settings, reported unsupported or failed measurements as inconclusive, and awaited temporary-database cleanup. Instrumentation adds overhead, so this is a research adaptation rather than byte-identical execution of the upstream library.

The measurement settings

Setting Value
Payload per write 16 KiB, zero-filled byte array
Transactions per timed block 15 sequential single-put transactions
Warmup One relaxed block, then one strict block, discarded
Measured rounds per run At least 7, at most 15
Soft time budget 1,000 ms, checked after a completed round once the minimum is reached
Ratio per round Strict block duration divided by relaxed block duration
Run-level aggregation Sorted ratios, upper median when the count is even
Private-like threshold Ratio below 1.30

The time budget is not a hard one-second deadline. A slow block can exceed it. The detailed experiment also has bounded failure handling. The short seven-round console example is kept separately in the download and was not used as the controlled study’s measurement engine.

All recorded controlled runs completed with measured results. We kept an earlier exploratory dataset outside these counts because it briefly overlapped local verification work. The two main filesystem datasets were collected sequentially.

Findings

1. Disk-backed contexts separated on this host

For the main, relaxed-first comparison, the normal default context had a median run ratio of 31.02 in headed Chrome and 29.82 in headless Chrome. The isolated contexts were close to 1.00.

Every main run in the disk-backed default contexts stayed above 1.30. Every main run in the disk-backed isolated contexts stayed below it.

That supports the technique’s usefulness under these conditions. It does not yet establish that the signal uniquely identifies private mode.

2. A RAM-backed normal profile produced the same low ratio

Next, we placed the persistent profile on tmpfs, a RAM-backed filesystem, and repeated the context comparisons. The default context still retained its marker across a browser restart, but its timings now looked private-like.

All 160 main-run ratios plotted across eight conditions. Disk-backed persistent contexts have ratios around 21 to 40. Isolated contexts and RAM-backed persistent contexts cluster near 1, below the 1.30 decision threshold.
Each point is one recorded relaxed-first run. Twenty runs per row, on one host and Chrome build. The ratio axis is logarithmic. The line marks the method’s 1.30 threshold, not a verified boundary between normal and private browsers.
Profile filesystem Chrome Context Runs Median ratio Range Below 1.30
Disk-backed Headed Persistent default 20 31.0158 20.9565–39.5185 0
Disk-backed Headed Isolated nondefault 20 1.0000 0.9167–1.0714 20
Disk-backed Headless Persistent default 20 29.8194 22.3529–33.8333 0
Disk-backed Headless Isolated nondefault 20 1.0000 0.8889–1.1429 20
RAM-backed Headed Persistent default 20 1.0000 0.8636–1.0556 20
RAM-backed Headed Isolated nondefault 20 1.0000 0.9231–1.1111 20
RAM-backed Headless Persistent default 20 1.0000 0.8966–1.1818 20
RAM-backed Headless Isolated nondefault 20 1.0000 0.9091–1.0909 20

The table reports the ordinary statistical median across run-level ratios. Each run itself uses the upper-median convention described above. The manual website’s aggregate display also uses an upper median, so these aggregation steps should not be treated as interchangeable.

If we interpreted every below-threshold result as proof of private mode, we would mislabel 40 of 40 RAM-backed normal/default runs in this main comparison. That is an observed false-positive count for this setup, not a population error rate.

A profile’s persistence across browser restarts, its filesystem, and the browser’s private-context designation are different properties. A temporary RAM filesystem can retain files across a browser restart while the host remains running. It does not provide the same persistence as surviving a host reboot.

The counterexample is enough to reject the universal claim that a low ratio proves Incognito. It is consistent with a storage-latency explanation, but changing the profile filesystem does not isolate every contributor to transaction timing.

3. Reversing order did not fix the counterexample

Always measuring relaxed writes first could introduce an order effect. We therefore ran ten additional measurements per condition using an alternating-order series.

The disk-backed default contexts remained above the threshold. The RAM-backed default contexts remained below it, including all 20 normal/default sensitivity runs across headed and headless Chrome.

Some timings moved, but the central counterexample survived. The download retains all 80 sensitivity runs alongside the 160 main runs, including both block durations and individual transaction samples.

Chromium changed underneath the explanation

A detector’s explanation can age even while its output continues to look plausible.

The upstream comments describe an in-memory LevelDB explanation. In the version-matched Chrome 154 source, backend selection instead directs in-memory contexts to SQLite after test and force overrides. The SQLite implementation has an in-memory opening path and changes its synchronization setting between strict and other transactions.

This source evidence supports rechecking the mechanism across versions. It does not mean the ratio itself identifies SQLite, LevelDB, or a physical storage medium. Our controlled profile directories contained LevelDB-related IndexedDB files, but we did not trace the isolated context’s backend at runtime.

A December 2025 Chromium engineering discussion also described observable differences during the in-memory SQLite transition. Its transaction-scheduling demonstration is a different technique from the durability ratio tested here. It should not be presented as validation of this exact algorithm or threshold.

The relevant lesson is practical: pin the browser build and revalidate behavior when storage implementations change. A target milestone in an engineering discussion is not proof of a universal rollout.

What this means for scraping and anti-bot research

A page can observe behavior that does not appear in a user-agent string. Storage timing is one example of why browser identity includes more than a reported product name.

For automation engineers, there are two separate questions: which browser context does the framework create, and what does a measurement in that environment actually establish? An isolated session, persistent automation profile, RAM-backed profile, and ordinary private window are not interchangeable labels.

The same caution applies to provider evaluations. A valid comparison would need the exact API product and rendering settings, proof the JavaScript completed, repeated raw measurements, and failures. Receiving a page’s initial HTML would not show that its storage experiment ran.

We are not publishing a named provider comparison here. The current provider notes are preliminary observations without the configurations and repeated captures needed for that conclusion. They remain research leads rather than evidence about a vendor’s browser architecture.

This signal could be part of a broader browser-environment investigation. Its presence does not establish that an anti-bot product uses it, and a private-like classification is not a reason to call a visitor a bot.

Try the experiment

Our IndexedDB timing check runs after you choose Run experiment. It shows the timed blocks, repeated ratios, classification, inconclusive outcomes, and database-cleanup status. You can export the result locally.

The measurement code runs in the browser. It does not upload the timing results. Exported JSON includes self-reported environment details, so inspect it before sharing it. Treat the label as an interpretation of the measurement, even when the separation looks dramatic.

To inspect the detailed console implementation, use scripts/console.js in the download. It is self-contained and includes the upstream license notice. The short quick-console.js reproduces the fixed-seven-round demonstration. Its simpler cleanup requests deletion without awaiting completion, so use the detailed implementation when collecting evidence.

The central calculation is small:

const ratios = rounds.map(({ strictMs, relaxedMs }) =>
  relaxedMs > 0 ? strictMs / relaxedMs : Infinity
);
const ordered = [...ratios].sort((a, b) => a - b);
const medianRatio = ordered[ordered.length >> 1];
const timingSignal = medianRatio < 1.30
  ? "private-like"
  : "normal-like";

This excerpt illustrates the calculation after successful measurement. The complete implementation also handles unsupported APIs, timing failures, visibility and cleanup. It does not turn those failures into evidence of a normal window.

Limitations

This is one Chrome build on one Linux host. We did not test other operating systems, hardware, Chrome versions, Guest mode, or all storage policies. Safari and Firefox use different branches in the upstream library and are outside this study.

The repeated runs characterize these sessions, not independent samples of the browser population. CPU load, caches, filesystem behavior, scheduling and timer resolution can influence timings. We changed the profile filesystem and tested measurement order, but did not independently control every source of latency.

Normal/default persistence was checked with local storage. The isolated context’s runtime backend was not directly traced. Neither recognized durability hints nor elapsed times verify physical disk flushing, power-loss guarantees, or whether process memory could be swapped.

The manual screenshots contain displayed summaries and author-identified window modes. They are supporting demonstrations, with no retained per-transaction exports. The controlled datasets provide the detailed samples and context construction separately.

The 1.30 cutoff is the upstream heuristic, not a threshold we trained or validated across devices. A value above it is not proof of normal mode either. Upstream unsupported and error paths can return isPrivate: false, so a false result must not be treated as independent ground truth.

Reproduction

The reproduction package contains both controlled datasets, their summaries, the manual-observation record, licensed measurement scripts, and the source needed to run the Checks application. Local profile paths are replaced with placeholders in the public copies. Recorded timing values are unchanged.

For the full browser experiment, extract the package and run from its indexeddb-timing folder:

npm install
npm run build

# The headed comparisons require a graphical display.
CHROME_PATH=/usr/bin/google-chrome npm run measure

# Choose a directory verified to be on a RAM-backed filesystem.
ST005_LABEL=tmpfs ST005_PROFILE_ROOT=/tmp/st005-reproduction \
  CHROME_PATH=/usr/bin/google-chrome npm run measure

python3 scripts/summarize.py

Verify the profile filesystem before interpreting the labels. On the recorded host, the workspace was btrfs and /tmp was tmpfs. That is not guaranteed on another machine. The runner records the filesystem and full browser arguments, checks state retention and isolation, and removes the profiles it creates. New measurements overwrite the corresponding dataset names, so retain a separate copy of the recorded evidence first.

The package pins Puppeteer Core. Chrome is supplied by the caller, and matching the recorded build matters. The complete ScrapeTrace Checks repository also contains the reusable tool. No new provider requests or browser measurements were performed while preparing this article.

Disclosure

The original method is credited to detectIncognito, and adapted code retains its MIT copyright and license notice. This is independent research, with no sponsored provider comparison or affiliate ranking.

Measure the behavior. Test the inference. A timing signal is only as useful as the controls around it.

Found a mistake or a result you cannot reproduce?

Send a correctionReference: /articles/incognito-indexeddb-timing/

Research capture

100%