A WebAssembly backend for libusb
Applications that talk to USB hardware typically rely on a system-level library to abstract the platform's USB stack. libusb is one such library, written in C and used across operating systems. Moving such applications to the browser means reimplementing that abstraction layer against WebUSB, the browser API for interacting with approved USB devices. With Emscripten and Asyncify, it's possible to build a libusb backend that runs on the web.
Why a real backend instead of a shim
One approach to porting a C library like libusb is to provide a shim that mimics its API and link applications against that. This is brittle and hard to maintain. libusb itself is structured to avoid that problem: it separates a public API from internal "backends" that handle the low-level OS operations. There are already backends for Linux, macOS, Windows, Android, and others, all living in libusb/os. Adding a new platform means adding another backend there, implemented against the web platform's device APIs.
Backend structure
Each libusb backend includes the shared header libusbi.h and exposes a usbi_backend variable of type usbi_os_backend. This struct carries the backend name, capability flags, and function pointers for operations like device enumeration, open/close, and data transfers. It also declares sizes for private storage attached to device, context, and transfer objects—typically used to hold OS-specific handles.
In the web backend, those handles are WebUSB JavaScript objects. Emscripten's emscripten::val class, part of Embind, is the natural way to hold such objects in C++. The new backend file, libusb/libusb/os/emscripten_webusb.cpp, uses sizeof(val) for its private data fields and provides small helpers to construct, reference, and move val instances in and out of those allocated areas.
Handling async WebUSB calls in synchronous C
libusb's API is synchronous, while WebUSB methods return Promise objects. Asyncify, via Embind's val::await(), bridges that gap by suspending and resuming the C stack around asynchronous operations.
Error handling needs a workaround: Embind has no built-in way to catch JavaScript exceptions from C++. The solution is to catch rejections on the JavaScript side and convert them into a result object with error and value fields, using the EM_JS macro and Embind's Emval.to{Handle, Value} functions. C++ code then inspects those fields to map WebUSB failures to libusb error codes.
For example, opening a device involves taking a val that represents a USBDevice from the device handle, calling its open() method, awaiting the promise, and returning a status code.
Device enumeration and the permission model
Enumeration is where the web's security model forces a different design. On a desktop OS, a process can list all connected devices. On the web, there is no such global enumeration. Instead, an application first calls navigator.usb.requestDevice()—which shows a permission prompt and requires a user gesture—to obtain access. After that, navigator.usb.getDevices() lists only the devices the user has already approved.
The backend's get_device_list handler therefore cannot call requestDevice() by itself; doing so would fail because libusb applications typically enumerate devices at startup, not inside a click handler. The responsibility for invoking requestDevice() rests with the end developer, while the backend only exposes already-permitted devices via navigator.usb.getDevices().
Platform caveats
Browser USB support is not uniform across operating systems. On Windows, DSLR cameras and other "well-known" devices are claimed by a system driver that WebUSB cannot access. Tools like Zadig can override that driver, but that's a manual action with some risk. Linux may require custom udev permissions depending on the distribution. macOS and Android generally work out of the box, with a caveat for phone users: the demo interface is not responsive and should be used in landscape mode.
The complete backend code, including transfer handling details not covered here, is available in libusb's repository under the emscripten_webusb.cpp file.
Event handling without a native poll()
Handling events is one of the trickier parts of porting synchronous C libraries like libusb. In a native environment, the typical pattern is an infinite loop that polls a set of external I/O sources and dispatches events to handlers as they become available. This model does not translate directly to the browser.
There are several reasons for this. WebUSB cannot expose raw device handles, so there is nothing to poll directly. Additionally, libusb itself relies on eventfd and pipe for signaling, neither of which works as expected under Emscripten: eventfd is unsupported, and the current pipe implementation cannot wait for events.
The core issue, however, is that the web has its own global event loop. Running a second, nested, blocking loop inside the main thread prevents the browser from ever processing the very I/O completions the code is waiting for, resulting in a deadlock and a frozen page.
There are two main ways to approach this. You can refactor the application to perform all blocking I/O on a separate thread, or you can use Asyncify to pause the call stack and yield control back to the browser's event loop. As I was already using Asyncify for Promise integration and wanted to avoid significant changes to libusb or gPhoto2, I chose the latter.
My first proof-of-concept used a busy-wait loop to simulate a blocking poll():
#ifdef __EMSCRIPTEN__
// TODO: optimize this. Right now it will keep unwinding-rewinding the stack
// on each short sleep until an event comes or the timeout expires.
// We should probably create an actual separate thread that does signaling
// or come up with a custom event mechanism to report events from
// `usbi_signal_event` and process them here.
double until_time = emscripten_get_now() + timeout_ms;
do {
// Emscripten `poll` ignores timeout param, but pass 0 explicitly just
// in case.
num_ready = poll(fds, nfds, 0);
if (num_ready != 0) break;
// Yield to the browser event loop to handle events.
emscripten_sleep(0);
} while (emscripten_get_now() < until_time);
#else
num_ready = poll(fds, nfds, timeout_ms);
#endif
This approach had two clear drawbacks. Each iteration of the loop would save and restore the entire call stack with Asyncify, even when no USB events were pending. Furthermore, setTimeout() has a minimum delay of 4ms in modern browsers, adding a fixed latency to every loop. Despite this, the approach was functional and produced a 13-14 FPS livestream from a DSLR in the proof-of-concept.
To improve efficiency, I replaced the constant polling with a custom event-based notification system. This was done using the EM_ASYNC_JS macro to emit custom events on the global object, without tying them to a particular libusb data structure:
EM_JS(void, em_libusb_notify, (void), {
dispatchEvent(new Event("em-libusb"));
});
EM_ASYNC_JS(int, em_libusb_wait, (int timeout), {
let onEvent, timeoutId;
try {
return await new Promise(resolve => {
onEvent = () => resolve(0);
addEventListener('em-libusb', onEvent);
timeoutId = setTimeout(resolve, timeout, -1);
});
} finally {
removeEventListener('em-libusb', onEvent);
clearTimeout(timeoutId);
}
});
The notify function is triggered whenever libusb reports an event, such as the completion of a data transfer:
void usbi_signal_event(usbi_event_t *event)
{
uint64_t dummy = 1;
ssize_t r;
r = write(EVENT_WRITE_FD(event), &dummy, sizeof(dummy));
if (r != sizeof(dummy))
usbi_warn(NULL, "event write failed");
#ifdef __EMSCRIPTEN__
em_libusb_notify();
#endif
}
The wait function then resumes execution from an Asyncify sleep when it receives an em-libusb event or the timeout expires:
double until_time = emscripten_get_now() + timeout_ms;
for (;;) {
// Emscripten `poll` ignores timeout param, but pass 0 explicitly just
// in case.
num_ready = poll(fds, nfds, 0);
if (num_ready != 0) break;
int timeout = until_time - emscripten_get_now();
if (timeout <= 0) break;
int result = em_libusb_wait(timeout);
if (result != 0) break;
}
This change dramatically reduced the number of unnecessary sleeps and wake-ups. By eliminating the overhead of the earlier implementation, it increased the livestream throughput from 13-14 FPS to a consistent 30+ FPS.
Build configuration and first run
Integrating the new backend into the build system only required a few additions to Makefile.am and configure.ac, with the most interesting part being the Emscripten-specific linker flags:
emscripten)
AC_SUBST(EXEEXT, [.html])
# Note: LT_LDFLAGS is not enough here because we need link flags for executable.
AM_LDFLAGS="${AM_LDFLAGS} --bind -s ASYNCIFY -s ASSERTIONS -s ALLOW_MEMORY_GROWTH -s INVOKE_RUN=0 -s EXPORTED_RUNTIME_METHODS=['callMain']"
;;
Three flags are critical here. First, Emscripten determines its output format based on the requested file extension. Setting EXEEXT to .html ensures that all executables in the package—tests and examples—become HTML files that use the default Emscripten shell to load the JavaScript and WebAssembly. Second, the --bind, -s ASYNCIFY, and -s ALLOW_MEMORY_GROWTH flags enable the Embind and Asyncify features needed by the port. As a library cannot propagate these requirements to the linker, any dependent application must also specify them in its own build configuration.
Finally, because WebUSB requires a user gesture for device enumeration, the common assumption that devices can be enumerated at startup is invalid. I disabled automatic startup with -s INVOKE_RUN=0 and exported the callMain() method via -s EXPORTED_RUNTIME_METHODS=... so that the executables could be triggered manually.
After these changes, serving the generated files from a static web server and initializing WebUSB in DevTools was enough to run the tests and examples.
![Screenshot showing a Chrome window with DevTools open on a locally served `testlibusb` page. DevTools console is evaluating `navigator.usb.requestDevice({ filters: [] })`, which triggered a permission prompt and it's currently asking the user to choose a USB device that should be shared with the page. ILCE-6600 (a Sony camera) is currently selected.](https://web.dev/static/articles/porting-libusb-to-webusb/image/screenshot-showing-chrom-b19a9e4e1b916.png)
![Screenshot of the next step, with DevTools still open. After the device was selected, Console has evaluated a new expression `Module.callMain(['-v'])`, which executed the `testlibusb` app in verbose mode. The output shows various detailed information about the previously connected USB camera: manufacturer Sony, product ILCE-6600, serial number, configuration etc.](https://web.dev/static/articles/porting-libusb-to-webusb/image/screenshot-the-next-step-bd4e4c7f85be7.png)
This may not look like much, but getting valid output from a freshly ported low-level C library for the first time is a major milestone.
Building your own application against the port
If you want to use this port in your own project, the setup requires a few specific steps:
- Fetch the latest libusb, either as a build-time archive or a git submodule.
- Run
autoreconf -fivinside thelibusbdirectory. - Configure for cross-compilation with
emconfigure ./configure –host=wasm32 –prefix=/some/installation/path. - Build and install with
emmake make install. - Point your application or a higher-level library to the installation path to find libusb.
- Add
--bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTHto your application's linker flags.
The port still has some limitations, mostly stemming from the capabilities of WebUSB and its underlying operating systems:
- No transfer cancellation. This reflects a lack of cross-platform support for cancellation in libusb itself.
- No isochronous transfers. The implementation pattern for other transfer modes provides a clear path for adding it, but due to the rarity of the mode and a lack of test hardware, it remains unimplemented.
- Cross-platform access restrictions. These are system-level limitations. For those dealing with HID or serial devices rather than raw USB, porting a library like hidapi to WebHID could sidestep these issues entirely.
The effort of porting foundational libraries is worthwhile because it enables the broader ecosystem of higher-level libraries and applications to run on the web, making them accessible on any device with a browser. A follow-up post details the construction of a full gPhoto2 demo that puts the transfer capabilities of this libusb port to extensive use.



