WebUSB, WebSerial & Web Bluetooth: How Hardware APIs Behave in Headless vs Headed Browsers

12 min read

Most pages never touch hardware APIs, and most automation setups never think about them. That is exactly why they make a useful case study: three small browser APIs that behave very differently depending on whether a person is sitting in front of the window.

Here is the pattern. A page calls navigator.usb.requestDevice({filters: []}) and waits. A headed browser shows a USB picker dialog and waits for the person to act. A headless browser has no UI to show, so the call fails almost instantly or never resolves. The gap is milliseconds versus "until a human decides."

If you run a site, you can use that gap to understand your own traffic. If you run browser tests or automation, you need to know it exists so your results match what real users see. This tutorial covers both.

What We're Building

By the end of this, you'll understand:

  1. How WebUSB, WebSerial and Web Bluetooth work and why they expose browser mode
  2. Why headless browsers behave differently with them
  3. What a site owner or QA engineer can observe from these APIs
  4. How to audit your own multi-account browser profiles against them
  5. Why engine-level support is more predictable than JavaScript wrappers

This post pairs with the battery and sensor API leaks tutorial — different hardware vectors, same underlying idea: APIs that assume real hardware and real user interaction.

Prerequisites

  • A browser profile to test (JustBrowser or any Chromium-based browser works for diagnostics)
  • Chrome DevTools open — we'll be running console commands
  • Basic understanding of the permission model in browsers (HTTPS, user gestures)
  • A USB device plugged in (optional, but useful for comparison against regular headed Chrome)

Step 1: Understanding the Hardware-Bridge Trio

These three APIs let web pages talk to physical hardware:

WebUSB — connects to USB devices (Arduino boards, hardware wallets, 3D printers, certain authentication keys)

WebSerial — connects to serial ports (COM ports on Windows, /dev/tty* on Mac, Arduino serial interfaces)

Web Bluetooth — connects to Bluetooth LE devices (fitness trackers, smart home devices, certain peripherals)

All three share critical security constraints:

  1. Secure context required — HTTPS only (or localhost)
  2. User gesture required — must be triggered by click/keypress, not script-only
  3. Permission dialog required — browser shows device picker, user must select

And here's what matters for observing browser mode: headless browsers can't render permission dialogs. They have no UI. When these APIs try to show a picker, headless mode either fails fast with an error or leaves the call hanging. Either outcome looks different from a person working through a dialog in a headed window.

A page doesn't need you to own any USB devices to see this. It only needs to observe how your browser handles the request.

Step 2: WebUSB Behavior

The WebUSB API lives at navigator.usb. Here's the basic enumeration:

// Check if API exists
console.log('WebUSB available:', 'usb' in navigator);

// Try to get already-paired devices (no dialog needed)
if (navigator.usb) {
  navigator.usb.getDevices().then(devices => {
    console.log('Paired USB devices:', devices.length);
  });
}

The existence check is trivial. The interesting part is what happens when you call requestDevice():

// This REQUIRES user gesture in real browsers
navigator.usb.requestDevice({ filters: [] })
  .then(device => console.log('Selected:', device))
  .catch(err => console.log('Error type:', err.name, '| Message:', err.message));

In headed Chrome with a real user clicking a button:

  • Browser shows USB device picker
  • User sees list of connected devices
  • If user cancels: NotFoundError: No device selected.
  • If user selects: returns USBDevice object
  • Timing: hundreds of milliseconds to seconds (waiting for human)

In headless Chromium (Puppeteer, Playwright):

  • SecurityError or NotAllowedError when there's no user gesture or no picker to show
  • Sometimes TypeError if the API stub is incomplete
  • Timing: near-instant

A page that wants to tell the two apart can measure it:

// Call this from a real click handler: requestDevice() needs a user gesture,
// and without one every browser, headed or headless, rejects immediately
async function checkWebUSB() {
  const start = performance.now();
  try {
    await navigator.usb.requestDevice({ filters: [] });
    return { headless: false, timing: performance.now() - start };
  } catch (err) {
    const elapsed = performance.now() - start;
    // A person needs time to see the dialog and cancel it;
    // a browser that can't show the dialog rejects right away
    return {
      headless: elapsed < 100,
      errorType: err.name,
      timing: elapsed
    };
  }
}

If a call made from a real click still rejects in under 100ms, nobody interacted with a dialog: the browser couldn't show one, or the API is stubbed. For a site owner that is a useful traffic-quality signal; for a tester it is a reminder that a headless run is not a perfect stand-in for a person.

Step 3: WebSerial Behavior

WebSerial is similar but with its own quirks:

console.log('WebSerial available:', 'serial' in navigator);

if (navigator.serial) {
  navigator.serial.getPorts().then(ports => {
    console.log('Previously granted ports:', ports.length);
  });
}

The requestPort() call follows the same user-gesture-and-dialog pattern:

navigator.serial.requestPort()
  .then(port => console.log('Port:', port))
  .catch(err => console.log('Error:', err.name));

Headed browser flow:

  1. User clicks button triggering requestPort()
  2. Browser shows serial port picker (lists COM ports or /dev/tty*)
  3. User selects or cancels
  4. Promise resolves or rejects with NotFoundError

Headless flow:

  • NotAllowedError — transient activation required
  • Or API missing entirely
  • Near-instant rejection

You can also check the prototype chain of navigator.serial:

// Real WebSerial has specific internal slots
console.log(Object.getPrototypeOf(navigator.serial).constructor.name);
// Should be 'Serial' in real Chrome
// Wrapped/polyfilled versions show 'Object' or custom names

Native implementations report the real interface name. A JavaScript wrapper reports something else, which is why wrapping browser APIs in script is fragile. If you need predictable behavior, rely on the engine rather than on page-level patches. See our audio, font and hardware fingerprinting guide for other areas where script-level overrides behave inconsistently.

Step 4: Web Bluetooth Behavior

Web Bluetooth adds OS-level permissions, which makes the picture a little more varied:

console.log('Web Bluetooth available:', 'bluetooth' in navigator);

if (navigator.bluetooth) {
  navigator.bluetooth.getAvailability().then(available => {
    console.log('Bluetooth adapter present:', available);
  });
}

The requestDevice() pattern again:

navigator.bluetooth.requestDevice({
  acceptAllDevices: true  // Show all BLE devices
})
  .then(device => console.log('Device:', device.name))
  .catch(err => console.log('Error:', err.name, err.message));

Even headed desktop browsers often lack Bluetooth adapters, so the type of error matters more than the fact of failure:

ScenarioError TypeTiming
Headed, user cancelsNotFoundErrorHuman-paced
Headed, no adapterNotFoundError with specific messageFast
HeadlessNotAllowedError or TypeErrorNear-instant
Wrapped APITypeError on internal slot accessNear-instant

The combination of error type and timing tells a page which mode it is running in. Because plenty of headed desktops have no Bluetooth adapter, a failure on its own says little; it's the error type and timing together that carry information.

Step 5: Running a Full Diagnostic

Here's a combined test you can run on any profile. Use it before relying on a new profile configuration, or on your own site's test environment:

async function hardwareBridgeAudit() {
  const results = {};

  // WebUSB
  results.webusb = { available: 'usb' in navigator };
  if (navigator.usb) {
    const start = performance.now();
    try {
      await navigator.usb.getDevices();
      results.webusb.getDevices = 'success';
    } catch (e) {
      results.webusb.getDevices = e.name;
    }
    results.webusb.getDevicesTiming = performance.now() - start;
  }

  // WebSerial
  results.webserial = { available: 'serial' in navigator };
  if (navigator.serial) {
    const start = performance.now();
    try {
      await navigator.serial.getPorts();
      results.webserial.getPorts = 'success';
    } catch (e) {
      results.webserial.getPorts = e.name;
    }
    results.webserial.getPortsTiming = performance.now() - start;
    results.webserial.prototypeValid =
      Object.getPrototypeOf(navigator.serial).constructor.name === 'Serial';
  }

  // Web Bluetooth
  results.bluetooth = { available: 'bluetooth' in navigator };
  if (navigator.bluetooth) {
    try {
      results.bluetooth.adapterAvailable =
        await navigator.bluetooth.getAvailability();
    } catch (e) {
      results.bluetooth.adapterError = e.name;
    }
  }

  console.log('Hardware Bridge Audit:', results);
  return results;
}

hardwareBridgeAudit();

Compare output between:

  1. Regular Chrome (headed, on your actual machine)
  2. Your JustBrowser profile, launched headed
  3. A raw Puppeteer/Playwright headless session for reference

The deltas show you what a page can observe. On regular headed Chrome, getDevices() and getPorts() succeed (returning empty arrays if no devices are paired). In headless sessions, compare carefully: some headless configurations remove or alter device APIs.

Step 6: Why JavaScript Wrappers Are Fragile

It is tempting to override navigator.usb with a stand-in object that returns empty arrays. It is a poor foundation for anything you need to rely on:

Timing. Native navigator.usb.getDevices() goes through the browser's own device service. A JavaScript stand-in that resolves a Promise has a different timing profile, which a page measuring timing can see, much like the behavioral timing patterns used in traffic analysis.

Internal slots. Real USBDevice objects have internal slots that JavaScript can't recreate. Methods like device.open() reach C++ bindings, and a fake object throws TypeError on internal slot access.

Cross-context differences. A wrapper in the main thread doesn't cover workers, which see the real API where it's exposed to them. The same inconsistency shows up with canvas, as the Worker isolation discussion explains.

Property descriptors. Overriding navigator.usb means making its descriptor configurable, which changes what Object.getOwnPropertyDescriptor(navigator, 'usb') returns.

The sturdier approach is an engine that implements these APIs itself. JustBrowser is built on a custom Chromium engine with native C++ integration rather than an extension, so each profile runs the browser's own implementation of WebUSB, WebSerial and Web Bluetooth instead of a script-level wrapper. Extension-based tools work at the JavaScript layer, below which these APIs live.

Common Errors and Fixes

Error: TypeError when calling navigator.usb.requestDevice()

What's happening: The WebUSB API is either missing or incompletely stubbed. Some headless configurations strip device APIs entirely.

The fix: Run the profile headed ({"headless": false} on the start call) when the page under test depends on these APIs. A page that expects the API takes a different code path when it's missing, so a test run without it no longer matches what users get.

Error: NotAllowedError with near-instant timing

What's happening: The browser is rejecting because there's no user gesture context. Headless mode can't process permission dialogs.

The fix: This is expected. If your test needs the dialog path, drive the page headed and trigger the call from a real click.

Error: API exists but prototype checks fail

What's happening: A JavaScript wrapper is overriding the native API. The object is present but doesn't have the correct internal structure.

The fix: Remove the page-level patch and use the engine's native implementation.

Error: Results are correct but arrive faster than a person could respond

What's happening: The API responses are right but the timing reflects a script, not a human at a dialog.

The fix: Nothing to fix: that is accurate behaviour for automation. If your own page treats very fast responses differently, write a test for that path on purpose.

Next Steps

These three APIs are niche. Most websites never call them. But site owners who analyze traffic quality sometimes do, because they're easy to probe and most automation ignores them.

  1. Run the diagnostic on every profile you operate. Document which APIs exist and how they behave.

  2. Compare against headed Chrome. On your actual machine, with real USB devices plugged in, what does the audit return? That's your reference state.

  3. Check your own pages. If you operate a site, decide whether you want to use these probes in traffic analysis, and document them in your privacy notice if you do. Our TLS fingerprinting explainer covers another layer of the picture.

  4. Re-test after browser updates. Chromium changes device API behavior between versions. JustBrowser patches every upstream Chromium release as it ships, but re-running the audit after an update is cheap. For how site owners combine signals like these, see our guide to how AI-driven bot detection works.

  5. Consider whether you need these APIs at all. If your pages and tests don't use hardware APIs, skip this entirely.

Frequently Asked Questions

Why do headless browsers behave differently with WebUSB?

Headless browsers can't render the USB device picker dialog that WebUSB requires. When navigator.usb.requestDevice() is called, headed browsers show a permission dialog listing connected USB devices. Headless mode either throws immediately, hangs, or returns an empty rejection, so the outcome and timing differ from a person interacting with the dialog.

Can WebSerial be reimplemented in JavaScript?

Not faithfully. WebSerial requires a secure context (HTTPS) and a user gesture to call requestPort(). The API returns SerialPort objects with specific internal slots and prototype chains, and a JavaScript wrapper behaves differently in timing and in prototype checks. If you need predictable behavior, use an engine that implements the API natively.

Do all multi-account browsers handle hardware APIs the same way?

No. Extension-based tools work at the JavaScript layer, while WebUSB, WebSerial and Web Bluetooth are implemented in Chromium's C++ device layer. JustBrowser is built on a custom Chromium engine with native C++ integration, so each profile runs the browser's own implementation of these APIs.

What's the simplest way to test WebBluetooth behavior?

Check whether navigator.bluetooth exists and what requestDevice() with a valid filter rejects with. Headed browsers reject with NotFoundError after the user cancels the dialog. Headless setups either lack the API, throw TypeError, or reject with NotAllowedError before any dialog could have appeared. The error type and timing show which mode a page is running in.


Try JustBrowser

A multi-account browser for teams, built on a custom Chromium engine with native C++ integration (not extension-based). Each profile is its own browser with its own cookies, storage, proxy and 40+ identity parameters. REST API and CDP for Playwright, Puppeteer, Selenium. 7-day free trial, card required — then $9.99/month or $99.99/year, unlimited profiles, free team seats.

Get started → · How it differs from Multilogin / GoLogin / AdsPower

Tags
  • webusb-webserial-fingerprinting
  • headless-detection
  • hardware-api-leaks
  • multi-account-browser
  • browser-fingerprinting
  • buildinpublic
  • saasstudio
  • aiworkforce
  • buildwithclaude

Ready to manage multiple accounts?

Seven days free, then $9.99/month — one plan, everything included.

We'd like to use Google Analytics, a Google service, to understand how our website is used. It sets two cookies in your browser and runs only if you click Accept. You can change your choice at any time with Cookie settings. Cookie Policy

Sign-in cookies and the cookie that remembers this choice are always on; the website needs them to work.

Google Analytics, a Google service, helps us understand how our website is used. It sets two cookies, _ga and _ga_TVZHQ99TZW. It is now onoff in this browser. If your browser sends a Global Privacy Control or Do Not Track signal, it stays off. Cookie Policy