The Web MIDI API is the smallest interesting API on the web platform. Four real interfaces, one entry point, one method that sends bytes. You can read the entire W3C specification in an afternoon. And yet almost every tutorial about it is either a copy of the same six-line snippet from 2015, or a library README that hides the thing you actually need to understand. This guide is the other kind: the whole surface, in order, with code that runs, and with the 2026 behaviour — including the permission change nobody updated their blog posts for.
- Entry point:
navigator.requestMIDIAccess()returns a promise for aMIDIAccessobject. Secure context only. - Spec status: W3C Working Draft, published 21 January 2025. Not a Recommendation.
- Two maps:
access.inputsandaccess.outputs, both maplike and keyed by a stable portid. - Sending:
output.send(data, timestamp). The timestamp shares an origin withperformance.now()and schedules ahead. - Permission: since Chrome 124 the whole API prompts, not just sysex. Design for denial.
- It makes no sound. Pair it with Web Audio, a hardware synth, or a virtual MIDI port into a DAW.
What does the Web MIDI API actually give you?
It gives you a list of MIDI ports and the ability to push bytes through them — nothing more, and that minimalism is deliberate.
The MDN overview lists eight interfaces, but only four of them matter when you are writing code: MIDIAccess, MIDIPort and its two subclasses MIDIInput and MIDIOutput, plus the MIDIMessageEvent you receive. MIDIInputMap and MIDIOutputMap are just the maplike types of the two collections hanging off MIDIAccess. MIDIConnectionEvent is what statechange hands you.
Crucially, there is no note abstraction, no channel object, no clock, no transport. The spec hands you a byte pipe to the operating system's MIDI subsystem — CoreMIDI on macOS, the Windows MIDI stack, ALSA sequencer on Linux — and then gets out of the way. If you want a Note On, you build the three bytes yourself. That sounds hostile until you realise it means the API never gets in the way of anything MIDI can express, including the weird stuff: 14-bit CC pairs, MPE, running status, device-specific sysex.
If you have never set up a virtual port on the receiving end, creating a virtual MIDI port is the companion piece — the browser can only talk to ports that already exist on the machine.
Is Web MIDI a finished standard?
No — it is a Working Draft, and has been for over a decade, which is a quirk of process rather than a sign of instability.
The current TR publication is dated 21 January 2025 and carries the standard Working Draft disclaimer: "a draft document and may be updated, replaced or obsoleted by other documents at any time". Day-to-day work happens in the editor's draft maintained by the W3C Audio Working Group. If you are checking a detail of behaviour, read the editor's draft; if you are citing the standard in a document, cite the TR URL.
In practice the shape of the API has not changed since Chrome shipped it. What has changed is the surrounding machinery — permissions, Permissions Policy integration, secure-context gating — and that is where posts written before 2024 go wrong. We cover that in detail in the Web MIDI permission model.
How do you request MIDI access without breaking on Safari?
Feature-detect for the method on navigator, then wrap the call itself in try/catch, because the two failure modes are completely different.
A browser with no implementation — Safari, every iOS browser, Firefox for Android — simply has no requestMIDIAccess property. Calling it throws a TypeError synchronously, which will not be caught by a .catch() on a promise that was never created. A browser that does implement it can still reject the promise: the user dismissed the prompt, an enterprise policy blocked it, or a Permissions Policy header denied the feature. The full support picture is in Web MIDI browser support in 2026.
// Robust entry point. Handles "API missing" and "API refused" separately,
// because they need different UI.
async function openMidi({ sysex = false } = {}) {
if (!('requestMIDIAccess' in navigator)) {
return { ok: false, reason: 'unsupported' };
}
try {
const access = await navigator.requestMIDIAccess({ sysex });
return { ok: true, access };
} catch (err) {
// SecurityError -> permission denied by the user or by policy
// NotSupportedError -> no MIDI subsystem available on this platform
return { ok: false, reason: err.name, error: err };
}
}
const result = await openMidi();
if (!result.ok) {
console.warn('No MIDI:', result.reason);
} else {
console.log('MIDI ready. Outputs:', result.access.outputs.size);
}
Two details in that snippet are load-bearing. First, the options object is the MIDIOptions dictionary from the spec, and it has exactly two members: sysex (default false) and software (controls whether software synthesisers are included in the port list). Second, access.outputs.size works because the maps are maplike — they have size, keys(), values(), entries() and forEach(), just like a real Map.
How do you enumerate MIDI ports correctly?
Iterate the values() of access.inputs and access.outputs and key your own state off port.id, never off array position.
MIDIPort exposes seven attributes. id is a unique identifier the spec says should remain stable across sessions for the same device — that is the thing you persist. name is the system-assigned port name and is what you show the user. manufacturer and version are often null depending on the platform. type is "input" or "output". state is "connected" or "disconnected". connection is "open", "closed" or "pending".
That last pair trips people up constantly. state is about the physical device. connection is about whether your page has the port open. A port can be state: "connected" and connection: "closed" at the same time, which simply means the hardware is there and you have not started using it yet.
function listPorts(access) {
const rows = [];
for (const port of [...access.inputs.values(), ...access.outputs.values()]) {
rows.push({
id: port.id,
name: port.name ?? '(unnamed)',
maker: port.manufacturer || '-',
type: port.type, // 'input' | 'output'
state: port.state, // 'connected' | 'disconnected'
connection: port.connection // 'open' | 'closed' | 'pending'
});
}
console.table(rows);
return rows;
} | Attribute | Type | What it is good for |
|---|---|---|
id | string | Persisting the user's device choice across reloads. |
name | string or null | The only thing a human will recognise. Show this. |
manufacturer | string or null | Disambiguating two devices with the same name. Often null. |
version | string or null | Rarely populated. Do not build logic on it. |
type | "input" or "output" | Filtering when you merge both maps into one list. |
state | "connected" or "disconnected" | Greying out ports whose hardware has gone away. |
connection | "open", "closed", "pending" | Knowing whether your own page holds the port. |
Why must you listen for statechange before anything else?
Because MIDI devices are hot-pluggable and your port list is stale the moment somebody knocks a USB cable.
MIDIAccess fires a statechange event whose port property is the port that changed. Individual MIDIPort objects fire their own statechange too, but the access-level one is the hook you want, because it also fires for devices that did not exist when you last enumerated.
access.addEventListener('statechange', (event) => {
const port = event.port;
console.log(`${port.type} "${port.name}" is now ${port.state} / ${port.connection}`);
// Re-render the picker. Do NOT trust any cached port object here.
renderDevicePicker(access);
// If the user's saved device just came back, re-bind to it.
if (port.id === savedOutputId && port.state === 'connected') {
bindOutput(port);
}
});
The re-binding case is what separates a demo from a tool. A Bluetooth controller that sleeps and wakes will come back with the same id on most platforms, so if you stored the id you can silently reconnect. The in-browser visualiser at /play does exactly this — it remembers the selected input by name and id and re-selects it when the device reappears, so you do not lose your device every time the pad naps. If your port never comes back at all, the port-not-showing-up checklist covers the OS-level causes.
How do you send a MIDI message?
Call output.send() with an array of raw MIDI bytes and, optionally, a timestamp to schedule it into the future.
The spec signature is send(sequence<octet> data, optional DOMHighResTimeStamp timestamp = 0). Bytes go in as a plain array or a Uint8Array. There is no note helper and no channel helper — a Note On for middle C at velocity 100 on channel 1 is [0x90, 60, 100], and a Note Off is [0x80, 60, 0]. The low nibble of the status byte is the channel, zero-indexed, so channel 10 is 0x99 for Note On.
const NOTE_ON = 0x90;
const NOTE_OFF = 0x80;
const CC = 0xB0;
function noteOn(out, channel, note, velocity, when = 0) {
out.send([NOTE_ON | (channel & 0x0f), note & 0x7f, velocity & 0x7f], when);
}
function noteOff(out, channel, note, when = 0) {
out.send([NOTE_OFF | (channel & 0x0f), note & 0x7f, 0], when);
}
function controlChange(out, channel, cc, value, when = 0) {
out.send([CC | (channel & 0x0f), cc & 0x7f, value & 0x7f], when);
}
// Play a C major triad for 400 ms, scheduled rather than timed with setTimeout.
const now = performance.now();
for (const n of [60, 64, 67]) {
noteOn(out, 0, n, 100, now + 10);
noteOff(out, 0, n, now + 410);
}
The masks are not decoration. MIDI 1.0 data bytes are seven bits; anything with the high bit set is a status byte. Send 128 as a velocity and the device will read it as the start of a new message and your stream desynchronises. The MIDI Association's message summary is the canonical table if you need to go beyond notes and CC, and the control change list tells you which CC numbers already have meanings you probably should not stomp on.
What does the send() timestamp really mean?
It is a point on the same high-resolution clock that performance.now() reads, and the browser queues the message until that moment arrives.
The spec is explicit: the timestamp is "milliseconds measured relative to the navigation start", a zero or past value means send immediately, and multiple calls with identical timestamps are delivered in call order. This is the single most underused feature of the API. It means you can schedule a whole bar of MIDI ahead of time in one synchronous burst and let the browser's MIDI thread deliver it, instead of relying on setTimeout, which is subject to timer clamping, main-thread jank and tab throttling.
The lookahead-scheduler pattern documented for Web Audio scheduling applies verbatim here, and for the same reason: JavaScript timers are advisory, but the audio and MIDI subsystems have their own clocks. Schedule into the future; never try to be on time from the main thread.
// Lookahead scheduler: emit MIDI 100 ms ahead of real time, 25 ms at a time.
const LOOKAHEAD_MS = 100;
const TICK_MS = 25;
let nextNoteTime = performance.now();
let step = 0;
const stepMs = (60000 / 120) / 4; // 16ths at 120 BPM
function scheduler(out) {
const horizon = performance.now() + LOOKAHEAD_MS;
while (nextNoteTime < horizon) {
if (step % 4 === 0) {
out.send([0x99, 36, 110], nextNoteTime); // kick, channel 10
out.send([0x89, 36, 0], nextNoteTime + 30);
}
nextNoteTime += stepMs;
step = (step + 1) % 16;
}
setTimeout(() => scheduler(out), TICK_MS);
}
There is a matching output.clear() method that drops everything still queued but not yet sent. If a sysex message is half-delivered, the implementation appends an 0xF7 terminator so the receiving device's parser does not hang. Call it on a panic button alongside an All Notes Off. If you are driving a sequencer rather than being driven by one, MIDI clock from a gamepad shows what the tick stream looks like.
How do you receive MIDI messages?
Attach a midimessage listener to a MIDIInput and read event.data, which is a Uint8Array of raw bytes.
Per MDN, the event also carries a timeStamp — the browser's best estimate of when the message actually arrived, which is more accurate than reading the clock inside your handler. Attaching a listener implicitly opens the port, which is why connection flips to "open" without you calling open() yourself.
function listen(input, onEvent) {
input.addEventListener('midimessage', (msg) => {
const [status, d1, d2] = msg.data;
const type = status & 0xf0;
const channel = (status & 0x0f) + 1;
if (type === 0x90 && d2 > 0) {
onEvent({ kind: 'noteon', channel, note: d1, velocity: d2, at: msg.timeStamp });
} else if (type === 0x80 || (type === 0x90 && d2 === 0)) {
onEvent({ kind: 'noteoff', channel, note: d1, at: msg.timeStamp });
} else if (type === 0xb0) {
onEvent({ kind: 'cc', channel, controller: d1, value: d2, at: msg.timeStamp });
} else if (type === 0xe0) {
onEvent({ kind: 'pitchbend', channel, value: (d2 << 7) | d1, at: msg.timeStamp });
}
});
}
Note the Note On with velocity zero branch. A great deal of real hardware, and most DAWs, send Note On, velocity 0 instead of a true Note Off, because it lets running status compress the stream. If you only handle 0x80 your notes hang forever. This is the single most common bug in first-draft Web MIDI code, and it is why the visualiser at /play treats both as a release.
Pitch bend is the other one worth internalising: it is 14-bit, little-endian across two seven-bit bytes, so the value is (d2 << 7) | d1 with 8192 as centre. The same packing applies to any 14-bit CC pair.
What about sysex?
Sysex works, but it is a separate, stronger permission and the browser actively filters it out if you did not ask.
Pass sysex: true in the options dictionary. If the permission is refused, the whole requestMIDIAccess() promise rejects with NotAllowedError — you do not get a degraded non-sysex access object as a consolation prize. So if sysex is optional in your app, request without it first and re-request with it only when the user opts in.
If you hold a non-sysex access object and try to send a message starting with 0xF0, send() throws InvalidAccessError. On the input side, sysex messages are silently dropped from the stream rather than causing an error. The security section of the spec explains the reasoning at length: sysex can read patches and samples out of a device and, on hardware with MIDI-updatable firmware, could in principle write to it.
// Universal Non-Realtime Identity Request - the safest sysex probe there is.
// F0 7E 06 01 F7, with 0x7F as the "all devices" broadcast id.
function identityRequest(out) {
out.send([0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7]);
}
// The reply arrives on the matching input as one long sysex message.
input.addEventListener('midimessage', (msg) => {
if (msg.data[0] === 0xF0 && msg.data[1] === 0x7E && msg.data[3] === 0x06) {
console.log('Identity reply:', [...msg.data].map(b => b.toString(16)));
}
}); We go deeper on device-specific dumps in the sysex deep dive, and on moving presets between machines in sysex preset transfer.
Does Web MIDI work in iframes and embeds?
Only when the embedding page explicitly allows it, because Web MIDI is a Permissions Policy controlled feature.
The Permissions-Policy header defines a midi directive. Its default allowlist is self, which means same-origin frames inherit it and cross-origin frames do not. If you embed a Web MIDI tool from another origin, you need an allow attribute on the iframe element. And, importantly, a policy denial cannot be rescued by the user granting permission — the spec is clear that if the feature is disallowed by policy, the user prompt never happens.
Where does Web MIDI stop being enough?
At the point where you need input the browser cannot see, or output that must survive the tab being hidden.
Web MIDI handles MIDI devices beautifully. It has no idea what a gamepad is. For that you need the Gamepad API, which is poll-based and has its own quirks, or WebHID when the gamepad reports in a format the Gamepad API will not normalise. Combining the two into one bridge is the subject of the browser bridge build guide.
The harder ceiling is lifecycle. A browser tab is throttled when hidden, and there is no way to register a MIDI service that keeps running after you close the window. If you want a controller that is simply available to every DAW on the machine, all the time, with no tab open, that is a native job — which is precisely what Universal Controller MIDI does. The browser is the best place to prototype a mapping and the worst place to depend on one during a show. We break the structural differences down in browser versus native MIDI latency.
A minimal but complete Web MIDI app
Everything above, assembled into roughly eighty lines you can paste into a file and serve from localhost.
The markup is three elements — a select with id outs, a button with id ping, and a div with id log styled white-space: pre-wrap. Everything else is this module script.
// midi-min.js - load it as a type="module" script from your page.
const outs = document.getElementById('outs');
const log = document.getElementById('log');
const say = (s) => { log.textContent = s + '\n' + log.textContent; };
let access = null;
let output = null;
function refresh() {
const prev = outs.value;
const options = [];
for (const port of access.outputs.values()) {
const opt = document.createElement('option');
opt.value = port.id;
opt.textContent = port.name + (port.state === 'connected' ? '' : ' (gone)');
options.push(opt);
}
outs.replaceChildren(...options);
if ([...access.outputs.keys()].includes(prev)) outs.value = prev;
output = access.outputs.get(outs.value) ?? null;
}
outs.addEventListener('change', () => { output = access.outputs.get(outs.value); });
document.getElementById('ping').addEventListener('click', () => {
if (!output) return say('No output selected.');
const t = performance.now();
output.send([0x90, 60, 100], t + 5);
output.send([0x80, 60, 0], t + 405);
say('Sent C4 on ' + output.name);
});
if (!('requestMIDIAccess' in navigator)) {
say('This browser has no Web MIDI. Try Chrome, Edge or Firefox on desktop.');
} else {
try {
access = await navigator.requestMIDIAccess({ sysex: false });
access.addEventListener('statechange', (e) => {
say(e.port.type + ' ' + e.port.name + ': ' + e.port.state);
refresh();
});
for (const input of access.inputs.values()) {
input.addEventListener('midimessage', (m) => {
say('IN ' + [...m.data].map(b => b.toString(16).padStart(2, '0')).join(' '));
});
}
refresh();
say('MIDI ready.');
} catch (err) {
say('MIDI refused: ' + err.name);
}
}
Serve it with any static server on localhost — localhost counts as a secure context, so you do not need a certificate to develop. Open it, approve the prompt, and you have a working MIDI monitor and sender in a single file with no dependencies.
Which pieces should you reach for a library for?
Reach for one when you find yourself writing a MIDI message parser for the third time, and not before.
WEBMIDI.js is the mature option: it wraps ports in objects with note helpers, named note strings, channel objects, and parsed events, and it handles the Note-On-velocity-zero case for you. Its source is on GitHub and is worth reading even if you never install it. Tone.js is not a MIDI library at all, but it is the standard answer for turning received messages into sound in the page, and we cover that pairing in the Tone.js live-coding rig.
What you should not do is reach for a library to avoid understanding the byte layout. Every real MIDI debugging session ends with you looking at hex, and a library that hides the hex makes that session longer.
FAQ
What is the Web MIDI API?
The Web MIDI API is a W3C specification that lets a web page enumerate the MIDI input and output ports attached to the host machine, read incoming MIDI messages, and send raw MIDI bytes back out. It is a transport API only, not a synthesiser.
Is the Web MIDI API a finished standard?
No. As of the 21 January 2025 publication it is still a W3C Working Draft, maintained by the Audio Working Group, with an editor draft tracked separately on GitHub. The core interfaces have been stable for years in practice, but the document itself is not a Recommendation.
Does the Web MIDI API let me play sounds?
Not on its own. Web MIDI moves MIDI bytes to and from devices. To make sound in the page you pair it with the Web Audio API, a library like Tone.js, or you route the messages to a hardware synth or DAW over a virtual MIDI port.
What does the timestamp argument to send() actually do?
It schedules the message for future delivery. The timestamp is a DOMHighResTimeStamp measured from the same time origin as performance.now(). A value of zero, an omitted value, or a time in the past all mean send as soon as possible.
Why does my MIDI port disappear halfway through a session?
Because the device was unplugged, slept, or dropped its Bluetooth link. MIDIPort has a state attribute that flips to disconnected, and MIDIAccess fires a statechange event. If you cached the port object and never listened for statechange, your UI keeps a dead reference.
Do I need a library to use Web MIDI?
No. The raw API is small enough to use directly, which is what this guide does. Libraries such as WEBMIDI.js are useful when you want named note helpers, channel objects and message parsing rather than raw byte arrays.
Can a web page send sysex with the Web MIDI API?
Yes, but only if you pass sysex true in the MIDIOptions dictionary and the user grants the stronger permission. Without it the browser silently filters sysex out of inputs and throws InvalidAccessError when you try to send it.
Does Web MIDI work in an iframe?
Only if the embedding page allows it. Web MIDI is governed by the midi Permissions Policy directive, and a cross-origin iframe is denied by default until the parent grants it with an allow attribute.
Try it, then outgrow it
Open the in-browser visualiser with any MIDI controller attached and you are looking at every idea in this post running live: a port picker keyed on id, a statechange listener that survives hot-plug, and a midimessage handler that treats Note On velocity zero as a release. It is deliberately small enough to read.
When you want the same mapping available to every application on the machine, without a tab open and without a permission prompt, that is the native side of the line. Download Universal Controller MIDI — the free tier runs one controller on one virtual port with three saved presets, which is plenty to find out whether a gamepad belongs in your rig. Pro is a one-time AUD $89 for unlimited controllers, ports and presets.