JavaScript Web MIDI Table
| Piece | What it does | Field note |
|---|---|---|
requestMIDIAccess() | The entry | Gesture-gated promise to a live MIDIAccess - inputs and outputs Maps that repopulate under you |
{sysex: true} | The second grant | System-exclusive needs its own scarier prompt - patch dumps and firmware live here; ask only when true |
statechange | The hot-plug law | Hubs re-enumerate, sleep drops ports - rescan inputs/outputs or your cached port plays to nobody |
midimessage bytes | The anatomy | [status, d1, d2]: 0x90 = note-on CHANNEL 1 - the low nibble is the channel, not decoration |
note-on/off pairing | The hang risk | Note-off must match note AND channel - mismatched pairs hang notes forever; CC 123 is the panic button |
send(msg, when) | The scheduler | Optional timestamp fires at OS-level precision without JS awake - the web sequencer primitive |
timeStamp | The recording truth | DOMHighResTimeStamp relative to page load - when it HAPPENED, not when the callback ran |
browser matrix | The support | Chromium family solid, Safari late, Firefox absent - feature-detect plus an honest fallback workflow |
Web MIDI connects the browser to thirty years of music hardware: keyboards, controllers, drum pads, synths, interfaces. navigator.requestMIDIAccess() resolves to a MIDIAccess object holding two maps - inputs (things that send you notes and knobs) and outputs (things you send messages to) - and from there MIDI is the same wire protocol the 1983 spec defined: small byte arrays, fast, device-to-device, no audio in the stream (MIDI carries events, not sound - the synth makes the noise).
Bottom line: the API is permissioned twice. The basic grant (notes, knobs, transport) needs a user gesture and may show a prompt; the SYSTEM EXCLUSIVE grant - sysex, needed for patch dumps, firmware updates and vendor-specific control - is a second, scarier permission you request explicitly with { sysex: true }. And access is a live handle: devices hot-plug constantly, so statechange is not optional - your inputs/outputs maps re-populate under you and code that cached a port reference at startup plays to nobody after the hub re-enumerates.
The honest part: Web MIDI is Chromium-family territory (Chrome, Edge, Opera; Safari finally shipped it in 2026-era releases, Firefox still lacks it), which means feature-detect and a graceful keyboard-mouse fallback for everything except actual MIDI hardware. The second honesty: MIDI messages are three bytes and unforgiving - status byte (0x90 = note on channel 1), data byte, data byte, with running-status and channel offsets as the two conventions that make byte-level bugs invisible until a note hangs forever because your note-off never matched the channel of your note-on.
How to use
- Request access inside a gesture: button.onclick = async () => { const access = await navigator.requestMIDIAccess(); } - add { sysex: true } only when you truly send system-exclusive data; the extra prompt costs trust, and browsers treat it as a distinct, higher-stakes grant.
- Treat inputs/outputs as live maps: access.inputs and access.outputs are Map-like, keyed by port id - enumerate at startup AND on statechange (access.onstatechange = e => rescan()), because hubs re-enumerate, sleep/wake drops ports, and controllers power themselves off mid-set.
- Listen at the port level: input.onmidimessage = (m) => { const [status, d1, d2] = m.data; ... } - the message carries a Uint8Array and m.timeStamp (ms relative to page load, not wall clock). Split status into command (high nibble) and channel (low nibble): 0x90 is note-on ONLY on channel 1.
- Send with the same discipline: const out = access.outputs.get(portId); out.send([0x90, 60, 100]); - note-off is 0x80 (or note-on with velocity 0, which some hardware prefers); send() takes an optional timestamp for scheduled messages, which is how you build a sequencer without setInterval jitter.
- Design for the unsupported branch: 'requestMIDIAccess' in navigator gates everything - on Firefox and older Safari, show the manual workflow (file import/export of MIDI files, virtual-port instructions) rather than a dead page; the Web MIDI ecosystem's own fallbacks are the pattern to copy.
Frequently asked questions
What is sysex and why does it need its own permission?
System Exclusive messages are the vendor-specific escape hatch of the MIDI spec: raw byte streams that mean whatever the manufacturer says they mean - patch dumps, firmware updates, deep editor control. The browser gates them separately because sysex is where MIDI becomes arbitrary device control: a firmware upload is a sysex stream, and the permission model treats 'this page may send arbitrary bytes to your hardware' as a different risk class from 'this page may hear your keyboard'. Request it only when the feature genuinely needs it ({ sysex: true } at request time), explain WHY in your UI before the prompt, and expect the second dialog - shipping both prompts in one first-touch flow is the standard way to lose half your users at the door.
Why do my notes hang when I stop the page mid-performance?
Because note-on is a state, not an event: a key-down message (0x9n) tells the synth to START a note, and only the matching note-off (0x8n, same note, same channel) stops it. If your page dies - tab close, exception, suspend - between the two, the hardware keeps sounding forever. The production hygiene: send all-notes-off (CC 123, or 0xBn, 123, 0) on visibilitychange/hidden and on any error path, wrap your port writers in try/finally so the release always runs, and remember the channel byte is part of the match - a note-off on channel 0 does not silence a note-on on channel 1, which is exactly the bug you wrote when you stripped the channel nibble.
How do timestamps and scheduling work in Web MIDI?
Every message carries a DOMHighResTimeStamp (m.timeStamp, milliseconds since page load) for received events, and send() accepts an optional timestamp parameter for outbound scheduling - the OS-level MIDI stack queues and fires it without JavaScript awake. That parameter is the whole trick behind web sequencers: schedule a bar of note events 100ms ahead, let the MIDI layer fire them on hardware clock discipline, and never build timing on setTimeout (its jitter is audible). For received data, timeStamp tells you when the event ACTUALLY happened relative to page time - needed for recording, where 'when the callback ran' is the wrong answer by tens of milliseconds.
Is Web MIDI ready for production use in 2026?
For Chromium-family web apps: yes - it has shipped stable for years, powers real web DAWs, and the hot-plug/sysex model is settled. The gap is the browser matrix: Firefox has no Web MIDI (its implementation stalled on the permission design), Safari shipped it late, and iOS support lags further behind, so any production page needs the feature-detect fallback and an honest 'works best in Chrome' note. The other production reality is hardware variance: MPE controllers, standalone sequencers and cheap USB-MIDI cables all behave differently at the byte level, so test against the oldest, cheapest controller you can find - the flaky 20-dollar cable is your real compatibility target, not the flagship workstation.