Ask how much browser MIDI latency costs you compared to a native bridge and you will get a number. Distrust the number. It was measured on somebody else's display at somebody else's refresh rate with somebody else's audio interface, and it tells you nothing about your machine. What is actually transferable is the structure: which stages exist in each stack, which of them you control, which of them the platform will measure for you, and which one is not a latency problem at all but a reliability problem wearing a latency costume.
- Do not trust quoted milliseconds. Measure
gamepad.timestamp,baseLatencyandoutputLatencyon your machine. - The browser adds a polling stage bounded by frame rate, where native uses OS callbacks.
- Jitter matters more than latency. A steady delay is playable; a variable one is not.
- Scheduling beats chasing.
send(data, timestamp)immunises queued events against main-thread jank. - Hidden tabs stop rAF. This is the real ceiling, and it is not fixable by tuning.
- Native does not fix Bluetooth. Transport latency belongs to the radio, not the software.
What are the stages, in each stack?
Six stages for a browser rig, four for a native one, and the extra two are where the structural difference lives.
| Stage | Browser | Native |
|---|---|---|
| Controller to OS | USB or Bluetooth report interval | Identical |
| OS to application | Browser's internal gamepad sampling | OS callback or blocking HID read |
| Application observes input | Polled at requestAnimationFrame cadence | Event-driven, or a dedicated thread at the device rate |
| Decision and mapping | Main thread, competing with rendering and GC | Dedicated thread, no render contention |
| MIDI write | MIDIOutput.send() into the platform queue | Direct call into CoreMIDI / Windows MIDI / ALSA |
| Port to consumer | Identical | Identical |
Stages one, two and six are the same in both. They are the operating system's problem and no application-level choice changes them. The stages that differ are three and four — how often you look, and what else is competing for the thread you look on.
Why does polling cadence matter?
Because the Gamepad API has no change events, so the age of your data is bounded by how often your loop runs.
The spec recommends sampling at the animation rate, immediately before the animation callbacks run. requestAnimationFrame fires at the display refresh rate — 60 Hz on most panels, 120 Hz on high-refresh displays. In the worst case, a button press lands just after a poll and sits in the operating system for a full frame before your code sees it.
A native bridge is not bound by a display. On macOS, GameController delivers a valueChangedHandler callback when state changes; on Linux, evdev delivers events on a file descriptor you block on; on Windows, GameInput and XInput have their own models. The read happens when the device reports, not when the screen refreshes. The platform-level detail is in the macOS GameController deep dive, the Linux evdev bridge and GameInput versus XInput.
The good news is you can measure your own staleness rather than reasoning about it, because Gamepad.timestamp is specified as the high resolution time at which the system last received new values.
// Measure how stale the gamepad data was when your loop saw it.
const samples = [];
function measureStaleness(padIndex = 0) {
function frame() {
requestAnimationFrame(frame);
const pad = navigator.getGamepads()[padIndex];
if (!pad) return;
// performance.now() and Gamepad.timestamp share a time origin.
const ageMs = performance.now() - pad.timestamp;
if (ageMs >= 0 && ageMs < 500) samples.push(ageMs);
if (samples.length === 600) {
samples.sort((a, b) => a - b);
const p = (q) => samples[Math.floor(samples.length * q)].toFixed(2);
console.log('input staleness ms - p50', p(0.5), 'p95', p(0.95), 'p99', p(0.99));
samples.length = 0;
}
}
requestAnimationFrame(frame);
}
measureStaleness(); Run that on your own hardware and you have a real distribution instead of a quoted average. Look at the p95 and p99, not the median — the spread is the part you feel.
Why is jitter worse than latency?
Because a consistent delay is something a player compensates for within minutes, and a variable one is something nobody can compensate for at all.
Drummers play ahead of a click. Guitarists on in-ear monitors adapt to a fixed send delay. A DAW applies plugin delay compensation. Every one of those is a correction for a known, stable offset. None of them can correct for an offset that changes from hit to hit, because there is nothing to correct toward.
This reframes the whole browser-versus-native comparison. The browser's main thread also runs layout, paint, your UI framework, and garbage collection. Any of those can delay the frame your poll lives in. The delay is not large, but it is irregular, and irregular is the expensive kind. A native bridge with a dedicated input thread has far less competing for it.
Which is why you should always report the spread, not the mean. Two rigs with the same average delay can feel completely different. The measurement discipline is the same one in the latency benchmark post.
What does the send() timestamp actually fix?
It fixes jitter, not latency — queued messages are immune to anything that happens on the main thread afterwards.
The Web MIDI send method takes an optional DOMHighResTimeStamp on the same clock as performance.now(). Zero or a past value means deliver immediately; a future value queues it. Once a message is queued, your main thread can stall for fifty milliseconds and the message still goes out on time, because delivery is not your thread's job any more.
What it cannot do is make anything happen sooner. If the user presses a button now and you want sound now, scheduling has nothing to offer — you are already late by the width of your poll interval. Scheduling helps sequenced and generated events, where you know what is coming. That is precisely why the lookahead scheduler pattern exists for Web Audio, and the identical pattern works for MIDI.
// Reactive events: nothing to schedule, send immediately.
function onButtonDown(out, note) {
out.send([0x90, note, 100]); // timestamp omitted = as soon as possible
}
// Generated events: schedule a window ahead, so jank cannot touch them.
function scheduleBar(out, startAt, stepMs, pattern) {
pattern.forEach((hit, i) => {
if (!hit) return;
const t = startAt + i * stepMs;
out.send([0x90, hit.note, hit.vel], t);
out.send([0x80, hit.note, 0], t + stepMs * 0.5);
});
}
The matching trap is setTimeout. Nested timers are clamped by the platform, and the clamp gets much more aggressive for hidden tabs. MDN documents the clamping behaviour. A sequencer built on raw timers inherits all of it; a sequencer built on scheduled sends uses the timer only to decide when to refill the queue, which does not need to be precise.
How do you measure MIDI round trip honestly?
Send to a virtual port that loops back into an input, and compare the event's timeStamp against the time you recorded before sending.
This measures the platform MIDI path only — not your audio, not your DAW — but it is the one stage people most often blame and least often measure. Create a virtual port with the IAC Driver, loopMIDI or an ALSA virtual port, select it as both the output and the input, and run this.
// loopback.js - measures the browser-to-OS-and-back MIDI path.
export async function measureLoopback(portName, count = 200) {
const access = await navigator.requestMIDIAccess({ sysex: false });
const out = [...access.outputs.values()].find(p => p.name === portName);
const inp = [...access.inputs.values()].find(p => p.name === portName);
if (!out || !inp) throw new Error('Loopback port not found: ' + portName);
const sentAt = new Map(); // marker byte -> performance.now()
const deltas = [];
inp.addEventListener('midimessage', (msg) => {
const marker = msg.data[1];
const t0 = sentAt.get(marker);
if (t0 === undefined) return;
sentAt.delete(marker);
// msg.timeStamp shares an origin with performance.now().
deltas.push(msg.timeStamp - t0);
});
for (let i = 0; i < count; i++) {
const marker = i % 128;
sentAt.set(marker, performance.now());
out.send([0xB0, marker, 64]); // CC, marker in the data byte
await new Promise(r => setTimeout(r, 15));
}
deltas.sort((a, b) => a - b);
const q = (p) => deltas[Math.floor(deltas.length * p)];
return {
n: deltas.length,
p50: q(0.5),
p95: q(0.95),
p99: q(0.99),
worst: deltas[deltas.length - 1]
};
} Note what this does not measure: the controller, your DAW's buffer, your audio interface, or the plugin. Those are separate stages and they are usually much larger. If the loopback numbers look fine and your rig still feels late, the problem is downstream, and the virtual MIDI routing chain post walks through every stage in order.
What do the audio numbers tell you?
If the sound is coming out of the browser, two of the stages are reported to you directly in seconds.
AudioContext.baseLatency is the latency incurred passing a buffer from the end of your audio graph into the host's audio subsystem. AudioContext.outputLatency estimates the time from that handover to the first sample reaching the output device. Both are doubles in seconds, both are specific to the context you are running right now.
These are the most honest latency numbers available anywhere in browser audio, because they come from the implementation rather than from a stopwatch. Print them in your UI. They also explain the single biggest real-world variable nobody accounts for: swapping to Bluetooth headphones changes outputLatency dramatically, and no amount of software tuning touches it. The full instrument build is in the Web Audio gamepad instrument.
What is the real ceiling on a browser rig?
Tab visibility — and it is not a latency problem at all, it is an availability problem.
requestAnimationFrame does not run while a tab is hidden. That is documented platform behaviour, it interacts with the Page Visibility API, and there is no flag to disable it. The consequence is blunt: alt-tab to your DAW to nudge a fader and your controller is inert until you come back. Any note held at that moment sustains forever, because the release never arrives.
A Web Worker does not rescue this. navigator.getGamepads() is a Window API and is not exposed to worker scopes, so there is nothing for a worker to poll. You can move downstream processing off the main thread; you cannot move the input read.
The least-bad mitigation is to make the failure loud rather than silent.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
panicAllNotesOff();
setBanner('Controller paused - this tab is in the background.');
} else {
setBanner('');
}
});
// A crashed renderer or a closed window takes the whole rig with it.
window.addEventListener('pagehide', panicAllNotesOff); The same category of problem covers the rest of the browser's reliability story: a renderer crash takes the controller out of every application at once, a browser auto-update mid-set does the same, and closing the window is indistinguishable from unplugging the device. None of these are things you tune. They are things you accept or you move off the tab.
What does native actually buy you?
Four things, only one of which is about speed.
- Event-driven or high-rate input. No display-refresh ceiling on how often you see the controller.
- A thread that is not rendering. Less competition means less jitter, which is the part you feel.
- It keeps running. No visibility gating, no tab, no window. The port exists whether or not you are looking at anything.
- Its own virtual port. A native bridge can publish a port rather than depending on one somebody else created — which also means it can publish several.
And three things it does not buy you. It does not fix Bluetooth, because transport latency belongs to the radio link and the host stack — that is covered in Bluetooth MIDI latency. It does not fix your DAW's buffer size. And it does not fix a poorly written mapping that sends four CCs where one would do.
So when is each one right?
Browser for anything you can restart; native for anything being recorded or performed.
| Situation | Browser | Native |
|---|---|---|
| Sketching a mapping | Best tool. Seconds per iteration. | Overkill. |
| Teaching or a classroom demo | A link, nothing installed. | Install friction. |
| Sharing an idea | A URL. Nothing beats it. | A download. |
| Controlling a DAW while you work in it | Breaks on every tab switch. | Correct choice. |
| Recording a take | Risky. | Correct choice. |
| Live performance | No. | Correct choice. |
| Two controllers, two ports | Cannot create ports. | Correct choice. |
| Gyro, touchpad, LEDs | WebHID, Chromium only. | Full access. |
Notice that most of the right-hand column is not about milliseconds. The honest summary of browser-versus-native is not "native is faster" — it is "native keeps working". Latency is the thing people argue about; availability is the thing that ruins takes.
FAQ
Is a browser MIDI bridge slower than a native one?
Structurally, yes, but the interesting difference is not the average delay. A browser adds a polling stage bounded by frame rate, runs on a main thread that competes with rendering, and stops entirely when the tab is hidden. A native bridge removes all three constraints.
How much latency does the browser add?
There is no single honest number, because it depends on your display refresh rate, your audio device, your transport and the load on the machine. The right approach is to measure the stages the platform exposes to you, which are gamepad.timestamp, AudioContext.baseLatency and AudioContext.outputLatency.
What is the difference between latency and jitter?
Latency is how long the delay is. Jitter is how much it varies from event to event. A consistent delay can be compensated by playing slightly early or by delay compensation; a variable one cannot, and it is what makes a rig feel loose even when the average number looks fine.
Does the send() timestamp reduce latency?
It does not reduce the floor, but it removes a large source of jitter. Scheduling a message for a future time hands it to the browser MIDI queue immediately, so later main-thread work cannot delay delivery. It cannot make anything arrive sooner than it otherwise would.
Why does a hidden tab break a browser controller rig?
Because requestAnimationFrame does not run while a tab is hidden, so the poll loop stops and the controller appears frozen until you switch back. It is defined platform behaviour, not a bug, and a Web Worker does not help because getGamepads is not exposed to workers.
Can a Web Worker keep the gamepad loop alive in the background?
No. navigator.getGamepads is a Window API and is not available in worker scopes, so there is nothing to poll from a worker. You can move downstream processing to a worker, but the input read itself has to happen on the main thread.
Does a native bridge fix Bluetooth latency?
No. Transport latency belongs to the radio link and the host Bluetooth stack, not to the software reading the device. A wired connection is the fix for that stage, in a browser or natively.
When is a browser bridge good enough?
When the tab stays focused and the stakes are low: sketching mappings, teaching, demos, studio experimentation, anything where you can restart. It is a bad fit for a recorded take or a live set, where a tab switch or a renderer crash takes the controller out of every application at once.
Measure first, then decide
Run the staleness probe and the loopback measurement on your own machine before you accept anyone's numbers, including these. Then open the visualiser with your controller bridged in and watch the response — your hands are a better instrument than a percentile table for deciding whether a rig feels right.
If the answer is "it feels fine until I switch tabs", that is the signal to go native. Universal Controller MIDI polls the controller in a process that does not stop when you look away, publishes its own virtual MIDI port, and reaches the inputs the Gamepad API never exposes. Free tier: one controller, one port, three saved presets. Pro: AUD $89 one-time, on macOS, Windows and Linux.