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

devtools-detection

v0.1.1

Published

Detect when browser devtools are open, with confidence-weighted detectors and no false-positive noise.

Downloads

49

Readme

devtools-detection

Detect when browser devtools are open, and react however you like.

  • Three detectors, weighted by trust. The noisy one corroborates; it never triggers on its own.
  • Sees undocked devtools. A worker-based debugger probe catches what window-size heuristics miss.
  • Nothing happens on import. No timers, no globals, no browser APIs touched until you call start(). Safe in a Next or Nuxt server bundle.
  • ~1.4 KB gzipped in your bundle, with no dependencies. npm's 30.8 KB "unpacked" figure is mostly type declarations, the standalone CDN build, and this README — none of which reach a browser.
npm install devtools-detection

Quick start

import { createDetector } from 'devtools-detection'

const detector = createDetector({
  onOpen: (state) => console.warn('devtools opened by', state.detectedBy),
  onClose: () => console.warn('devtools closed'),
})

detector.start()

onOpen and onClose fire on the transition, not on every poll. Call detector.stop() when you're done — it clears the timer and terminates the worker.

How detection works

Every technique here is a heuristic, and they fail in different places — which is exactly why the library runs several and weighs them differently.

| Detector | Default | Confidence | Sees undocked? | How it works | Where it fails | | --- | --- | --- | --- | --- | --- | | debugger | on | high | yes | Pings a worker that runs into a debugger statement. No reply means the breakpoint caught it. | Firefox only pauses workers while its Debugger panel is active. Defeated by deactivating breakpoints. | | size | on | low | no | Compares outerWidth/innerWidth. Docked devtools eats the difference. | Blind to undocked devtools. Sidebars, bookmark bars and zoom all trigger it. | | console | off | high | yes | Logs a value whose toString records that it ran. Browsers only format a logged value when a console is there to render it. | Fires for any attached devtools-protocol client, Playwright and Puppeteer included. Defeated by overriding console.log. Writes to the console. |

A detection counts as open once a high-confidence detector fires. size firing alone is reported but does not flip isOpen, which is where most of the false-positive noise in this category comes from.

detector.getState()
// { isOpen: true, confidence: 'high', detectedBy: ['debugger', 'size'], orientation: 'vertical' }

detectedBy lists everything that fired, including detectors below the threshold — so you can see a size-only signal without acting on it.

Why console is off by default

What it really detects is a console consuming output, and an automation client attached over the devtools protocol looks identical to an open devtools window. Left on, it reports devtools as open during every Playwright or Puppeteer run and silently trips your onOpen handler mid-test. Turn it on when that broader reading is what you want:

createDetector({ detectors: ['debugger', 'console', 'size'] })

Examples

Redirect to a blocked page

import { createDetector } from 'devtools-detection'
import { redirect } from 'devtools-detection/reactions'

createDetector({ onOpen: redirect('/blocked') }).start()

redirect() uses location.replace(), so it overwrites the current history entry instead of pushing a new one — the back button won't bounce them straight back.

Two things to get right here:

The blocked page must not run detection itself. If /blocked also detects devtools and redirects, it redirects to itself forever. Serve it as a plain page with no detector on it.

Give yourself a bypass, or you lock yourself out. Once armed, arriving with devtools still open fires the redirect again immediately, so you can never get back in to debug:

const bypassed = new URLSearchParams(location.search).get('token') === 'let-me-in'

createDetector({
  enabled: !bypassed,
  onOpen: redirect('/blocked'),
}).start()

Use a signed or hashed token in production rather than a guessable string — a plain flag is trivially discoverable by anyone reading your bundle.

Blank the page instead

import { blankPage } from 'devtools-detection/reactions'

createDetector({ onOpen: blankPage({ message: 'Nothing to see here.' }) }).start()

Send it to analytics

onOpen is just a callback, so anything works — this is the least invasive use and the one most likely to survive contact with real users:

createDetector({
  interval: 5000,
  onOpen: (state) => {
    navigator.sendBeacon('/api/events', JSON.stringify({
      type: 'devtools_opened',
      detectedBy: state.detectedBy,
      confidence: state.confidence,
    }))
  },
}).start()

React

subscribe and getState are shaped for useSyncExternalStore, so there is no wrapper package to install:

import { createDetector } from 'devtools-detection'
import { useEffect, useSyncExternalStore } from 'react'

// Created once, outside the component — creating it in render would
// build a new detector on every pass.
const detector = createDetector()

function useDevtools() {
  useEffect(() => {
    detector.start()
    return () => detector.stop()
  }, [])

  return useSyncExternalStore(detector.subscribe, detector.getState)
}

export function DevtoolsBadge() {
  const { isOpen, detectedBy } = useDevtools()

  if (!isOpen) return null
  return <aside>devtools detected via {detectedBy.join(', ')}</aside>
}

Listening from elsewhere in the app

If a callback is awkward to thread through, have it dispatch a DOM event instead:

createDetector({ emitEvent: true }).start()

window.addEventListener('devtoolschange', (event) => {
  console.log(event.detail) // the same DetectionState object
})

Tuning individual detectors

Pass instances rather than names when you want to configure one:

import { createDetector, debuggerDetector, sizeDetector } from 'devtools-detection'

createDetector({
  detectors: [
    debuggerDetector({ timeout: 300 }), // more patient on a slow device
    sizeDetector({ threshold: 220 }),   // fewer false positives from sidebars
  ],
}).start()

Only the cheap check

Dropping debugger is worth doing when you would rather never pause execution — it is the one detector that stops on a breakpoint, which is hostile to anyone genuinely debugging your site:

import { createDetector, sizeDetector } from 'devtools-detection'

createDetector({
  detectors: [sizeDetector()],
  minConfidence: 'low', // required, since `size` is low-confidence on its own
  onOpen: (state) => console.log('docked', state.orientation),
}).start()

Do this for the behaviour, not the bundle. The detectors are a few hundred bytes between them, so trimming the list barely moves the total — you lose undocked coverage and gain false positives from sidebars and zoom.

Without a build step

<script src="https://unpkg.com/devtools-detection"></script>
<script>
  DevtoolsDetection.createDetector({ onOpen: () => console.warn('hi') }).start()
</script>

API

createDetector(options?)

| Option | Type | Default | Notes | | --- | --- | --- | --- | | detectors | Array<DetectorName \| Detector> | ['debugger', 'size'] | Names, or instances from the factories below. | | interval | number | 1000 | Milliseconds between polls. | | minConfidence | 'low' \| 'high' | 'high' | Confidence a detection must reach to count as open. | | enabled | boolean | true | When false, start() does nothing. | | onOpen | (state) => void | — | Fires on the transition, not on every poll. | | onClose | (state) => void | — | Same. | | emitEvent | boolean | false | Also dispatch a devtoolschange CustomEvent on window. |

Returns:

| Member | Type | Notes | | --- | --- | --- | | start() | () => void | No-op if already running or enabled is false. | | stop() | () => void | Clears the timer, terminates the worker, resets state. | | getState() | () => DetectionState | Reference is stable until the state changes. | | subscribe(listener) | (() => void) => () => void | Returns an unsubscribe function. | | isRunning | boolean | Read-only. |

DetectionState

| Field | Type | Notes | | --- | --- | --- | | isOpen | boolean | Whether detection met minConfidence. | | confidence | 'low' \| 'high' | Best confidence among detectors that fired. | | detectedBy | readonly string[] | Everything that fired, threshold or not. | | orientation | 'vertical' \| 'horizontal' \| undefined | Only when a detector that tracks it fired. |

Detector factories

  • sizeDetector({ threshold = 170 }) — pixel gap that counts as devtools.
  • consoleDetector({ clear = true }) — whether to clear the console after each probe. Turning it off keeps your own logs at the cost of one blank line per poll.
  • debuggerDetector({ timeout = 150 }) — how long to wait for the worker's reply. Raise it if a busy main thread causes false positives.

Development

The debugger detector pauses on breakpoints — including yours. Give yourself an escape hatch, or developing against your own site gets tedious fast:

createDetector({ enabled: import.meta.env.PROD })

Or drop 'debugger' from detectors locally.

What this cannot do

Worth being straight about, because the alternative is an issue tracker full of surprises:

  • Anyone determined will get past it. Overriding console.log, deactivating breakpoints, or just editing the bundle all defeat detection. Treat this as a signal, not a lock.
  • It is not bot detection. The default detectors do not fire for Puppeteer or Playwright, which drive the browser over the devtools protocol without opening a devtools window. The opt-in console detector does fire for them, but as a side effect rather than a designed check — do not lean on it for that.
  • Mobile browsers have no devtools. In-page consoles like eruda and vConsole are not detected.
  • A strict Content Security Policy disables the debugger detector. Creating a worker from a blob: URL needs worker-src blob:. Without it the detector reports closed rather than throwing, and the others carry on.
  • Firefox is weaker than Chrome. The worker only pauses while the Debugger panel is active, which is why size stays in the default set as a backstop.

Good fits: analytics, easter eggs, nudging casual copying. Bad fit: anything you would call DRM.

License

MIT © Marko Reljic