WebUSB, WebSerial & Web Bluetooth: How Hardware APIs Behave in Headless vs Headed Browsers
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:
- How WebUSB, WebSerial and Web Bluetooth work and why they expose browser mode
- Why headless browsers behave differently with them
- What a site owner or QA engineer can observe from these APIs
- How to audit your own multi-account browser profiles against them
- 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:
- Secure context required — HTTPS only (or localhost)
- User gesture required — must be triggered by click/keypress, not script-only
- 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):
SecurityErrororNotAllowedErrorwhen there's no user gesture or no picker to show- Sometimes
TypeErrorif 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:
- User clicks button triggering
requestPort() - Browser shows serial port picker (lists COM ports or /dev/tty*)
- User selects or cancels
- 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:
| Scenario | Error Type | Timing |
|---|---|---|
| Headed, user cancels | NotFoundError | Human-paced |
| Headed, no adapter | NotFoundError with specific message | Fast |
| Headless | NotAllowedError or TypeError | Near-instant |
| Wrapped API | TypeError on internal slot access | Near-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:
- Regular Chrome (headed, on your actual machine)
- Your JustBrowser profile, launched headed
- 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.
-
Run the diagnostic on every profile you operate. Document which APIs exist and how they behave.
-
Compare against headed Chrome. On your actual machine, with real USB devices plugged in, what does the audit return? That's your reference state.
-
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.
-
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.
-
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
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.