· deepdives  · 7 min read

Diving Deep into the WebUSB API: Unlocking Direct Communication with Hardware Devices

Learn how the WebUSB API lets web pages talk directly to USB hardware. This in-depth guide explains how WebUSB works, shows step-by-step examples for printers and microcontrollers, covers permissions, security, debugging and platform quirks, and links to helpful resources.

Learn how the WebUSB API lets web pages talk directly to USB hardware. This in-depth guide explains how WebUSB works, shows step-by-step examples for printers and microcontrollers, covers permissions, security, debugging and platform quirks, and links to helpful resources.

What you’ll be able to do after reading this

Build web apps that communicate directly with USB hardware - print receipts to thermal printers, read sensors from microcontrollers, and control bespoke peripherals - all from the browser. Short setup. A bit of firmware awareness. No native app required. The browser becomes your device interface.

Why WebUSB matters - outcome first

Imagine clicking a button in a web page and having a connected device respond instantly. No drivers to install. No native packaging. Just HTTPS, a button, and your hardware talking with JavaScript. That’s the outcome WebUSB enables. It’s not magic. It’s a modern bridge between the web platform and USB devices - with careful security safeguards.

What WebUSB is (briefly)

WebUSB is a browser API that exposes low-level USB device access to web pages running in secure contexts (HTTPS). It gives JavaScript the ability to: discover devices, request user permission, open the device, claim interfaces, and perform control/bulk/interrupt transfers.

  • API: navigator.usb
  • Typical flow: requestDevice → open → selectConfiguration → claimInterface → transferIn/transferOut or controlTransfer

Learn more from the spec and MDN:

Browser & platform realities (short but important)

  • Chrome and Chromium-based browsers provide the most complete support. Other browsers have limited or no support.
  • Web pages must be served over HTTPS (or localhost).
  • Requesting device access requires a user gesture (e.g., click).
  • On Linux you may need udev rules so non-root users can access a device.
  • On Windows you may need to replace the device driver with WinUSB (tools such as Zadig). See below for tips.

Security and permission model - what to expect

  • Permission is origin-scoped and user-consented. A user must explicitly grant each site access to a device.
  • The API will not allow silent enumeration without consent; the user explicitly chooses devices via the browser chooser dialog.
  • Use filters to limit device options shown to users.
  • Some devices can include a WebUSB landing page descriptor to improve UX if firmware authorizes origins.

Read about security in the spec: https://wicg.github.io/webusb/#security-considerations

Basic WebUSB workflow (step-by-step)

  1. Request a device (user chooses device):
const filters = [
  // Prefer filters: use vendorId/productId for narrow chooser
  { vendorId: 0x2341 }, // example: Arduino vendor
  // Or use empty filters to allow all devices (not recommended)
];

const device = await navigator.usb.requestDevice({ filters });
  1. Open the device and prepare it:
await device.open();
if (device.configuration === null) {
  // Many devices require selecting configuration 1. Adapt as needed.
  await device.selectConfiguration(1);
}

// Choose the interface number you want to use (inspect device.configuration)
const ifaceNumber = 0; // example
await device.claimInterface(ifaceNumber);
  1. Inspect endpoints and perform transfers:
// Find an OUT endpoint number for writing
const iface = device.configuration.interfaces.find(
  i => i.interfaceNumber === ifaceNumber
);
const alternate = iface.alternates[0];
const outEndpoint = alternate.endpoints.find(e => e.direction === 'out');
const outEndpointNumber = outEndpoint.endpointNumber;

// Send raw bytes
const data = new Uint8Array([0x1b, 0x40]); // ESC @ (reset printer) as example
await device.transferOut(outEndpointNumber, data);

// Read data (from IN endpoint)
const inEndpoint = alternate.endpoints.find(e => e.direction === 'in');
if (inEndpoint) {
  const result = await device.transferIn(inEndpoint.endpointNumber, 64);
  const bytes = new Uint8Array(result.data.buffer);
  console.log(bytes);
}
  1. Release and close when done:
await device.releaseInterface(ifaceNumber);
await device.close();

Example: Printing to a thermal/ESC-POS printer

Thermal receipt printers commonly expose a bulk OUT endpoint that accepts ESC/POS commands. The general approach: open, claim the interface, and send ESC/POS bytes using transferOut.

Important: endpoint numbers and interface indices vary by device - inspect device.configuration to discover them.

Example code that prints “Hello from WebUSB” using ESC/POS:

async function printEscPos(device, ifaceNumber = 0) {
  await device.open();
  if (!device.configuration) await device.selectConfiguration(1);
  await device.claimInterface(ifaceNumber);

  const iface = device.configuration.interfaces.find(
    i => i.interfaceNumber === ifaceNumber
  );
  const outEp = iface.alternates[0].endpoints.find(e => e.direction === 'out');
  if (!outEp) throw new Error('No OUT endpoint found');

  const text = 'Hello from WebUSB\n';
  // Convert string to bytes (simple ASCII). For real use, use proper encodings.
  const encoder = new TextEncoder();
  const payload = encoder.encode(text);

  // Example: reset printer then print text then cut (cut command depends on printer)
  const resetCmd = new Uint8Array([0x1b, 0x40]); // ESC @
  await device.transferOut(outEp.endpointNumber, resetCmd);
  await device.transferOut(outEp.endpointNumber, payload);

  // Optional: some printers accept a cut command
  // const cutCmd = new Uint8Array([0x1d, 0x56, 0x41, 0x10]);
  // await device.transferOut(outEp.endpointNumber, cutCmd);

  await device.releaseInterface(ifaceNumber);
  await device.close();
}

Notes:

  • Use the TextEncoder for plain text. For images, format to printer-specific raster or ESC/POS image commands.
  • Some printers expect CRLF or specific line feeds.
  • If printing fails, inspect device.configuration and endpoints, and ensure the device driver on the OS isn’t holding the device.

Example: Talking to a microcontroller (sensor/command exchange)

Microcontrollers can present many USB classes. If the MCU exposes a custom vendor interface (recommended for WebUSB), you can read and write arbitrary endpoints.

This example assumes the device exposes a vendor-specific interface with one IN and one OUT bulk endpoint.

async function readSensor(device, ifaceNumber = 1) {
  await device.open();
  if (!device.configuration) await device.selectConfiguration(1);
  await device.claimInterface(ifaceNumber);

  const iface = device.configuration.interfaces.find(
    i => i.interfaceNumber === ifaceNumber
  );
  const alt = iface.alternates[0];
  const inEp = alt.endpoints.find(e => e.direction === 'in');
  const outEp = alt.endpoints.find(e => e.direction === 'out');
  if (!inEp || !outEp) throw new Error('Endpoints not found');

  // Request reading (vendor-specific control request example)
  // Or use bulk transfer protocol your firmware expects.
  // Here: we'll send a simple command and read the reply.
  const cmd = new TextEncoder().encode('READ_TEMP');
  await device.transferOut(outEp.endpointNumber, cmd);

  const reply = await device.transferIn(inEp.endpointNumber, 64);
  const data = new TextDecoder().decode(reply.data.buffer);
  console.log('sensor reply:', data);

  await device.releaseInterface(ifaceNumber);
  await device.close();
}

Important caveat: Many microcontrollers present as CDC-ACM (USB serial) devices. WebUSB does not automatically expose CDC function devices as a serial port; for serial communication prefer the Web Serial API which is purpose-built for virtual COM devices. See alternatives below.

Example: Using control transfers

Control transfers are useful for device-specific management (e.g., toggling modes, fetching descriptors). Example:

// vendor control transfer out
await device.controlTransferOut(
  {
    requestType: 'vendor',
    recipient: 'device',
    request: 0x01,
    value: 0x0001,
    index: 0x0000,
  },
  new Uint8Array([0x01])
);

// vendor control transfer in (read 64 bytes)
const res = await device.controlTransferIn(
  {
    requestType: 'vendor',
    recipient: 'device',
    request: 0x02,
    value: 0x0000,
    index: 0x0000,
  },
  64
);
console.log(new Uint8Array(res.data.buffer));

Device connect/disconnect events

You can react to device hotplug events:

navigator.usb.addEventListener('connect', event => {
  // A device was connected. You may prompt the user or auto-handle.
  console.log('USB device connected', event.device);
});

navigator.usb.addEventListener('disconnect', event => {
  console.log('USB device disconnected', event.device);
});

Practical debugging & platform tips

  • Inspect device metadata in devtools console: console.log(device) after requestDevice.
  • If the OS driver has claimed the device, the browser may not be able to claim it. On Windows, use Zadig to replace the driver with WinUSB for testing.
  • On Linux, add a udev rule so your user can access the device without sudo. Example rule (replace vendor/product IDs):
SUBSYSTEM=="usb", ATTR{idVendor}=="abcd", ATTR{idProduct}=="1234", MODE="0666"
  • If you get permission errors, ensure the browser has the device permission and the device is not already claimed by another driver.

Best practices & firmware considerations

  • Use vendor-specific interfaces or explicitly provide a WebUSB descriptor if you control firmware. That avoids conflicts with platform kernel drivers (CDC/HID) and avoids requiring driver replacement.
  • Keep a simple, documented transfer protocol (command/response, length prefixing, checksums) for reliable parsing.
  • Use small transfer sizes and handle partial transfers; check transferIn.result and transferOut.status.
  • Provide a friendly landing page in device firmware using the WebUSB landing page descriptor to guide users for initial pairing; see the WebUSB spec for details.

When WebUSB is not the right tool

Example checklist before shipping

  • Does your firmware expose a vendor interface or WebUSB descriptor? If not, can you change it?
  • Are udev and driver instructions documented for developers/testers?
  • Do you use filters in requestDevice to narrow choices for users?
  • Do you gracefully handle browser/device disconnects and recover?
  • Are you running over HTTPS and handling errors with clear UX?

Resources and further reading

Final note

WebUSB unlocks powerful possibilities: native-like device control with web reach and immediate deployability. Use it when you need direct USB access from a web app, design your firmware with web clients in mind, and respect the user’s security boundaries. When done right, you can deliver seamless, immediate hardware interactions straight from the browser.

Back to Blog

Related Posts

View All Posts »
Unlocking New Hardware Possibilities with the WebUSB API

Unlocking New Hardware Possibilities with the WebUSB API

Explore how the WebUSB API brings direct USB device access to web applications - from interactive IoT device control and data transfer to firmware updates and debugging. Learn how it works, see code examples, and discover best practices and limitations.