Blog Gamepad API 12 min read

Gamepad API Polling in JavaScript: Why No Button Events

A developer's guide to the W3C Gamepad API: the snapshot model, the user-gesture gate, standard mapping, deadzones, diffing and rumble, with code.

By Aidxn Design

The first thing every developer tries with the Gamepad API in JavaScript is gamepad.addEventListener('buttondown', ...), and the first thing every developer learns is that no such event exists. The W3C Gamepad API gives you exactly two events — one when a pad connects, one when it disconnects — and after that you are on your own with a polling loop. That design is not laziness. It is the right call for an input device that reports continuously, and once you understand why, the rest of the API stops being surprising.

TL;DR
  • Two events only: gamepadconnected and gamepaddisconnected. Everything else is polled.
  • Snapshots, not live objects: navigator.getGamepads() returns fresh objects every call. Never cache one.
  • User gesture required: no pad is exposed until the user presses something with the page visible.
  • Standard mapping: 17 buttons, 4 axes, fixed indices, mapping === 'standard'.
  • Poll in rAF: the spec recommends sampling at the same rate as your animation, immediately before the callbacks run.
  • Diff everything: without a deadzone and a change test you will emit garbage at 60 Hz.

Why does the Gamepad API have no button events?

Because the API was written for applications that already run a frame loop, and delivering input through an event queue would add latency without adding information.

The spec says this almost in those words. Under its coordination guidance it states that "the gamepad data should be polled as closely as possible to immediately before the animation callbacks are executed, and with frequency matching that of the animation. That is, if the animation callbacks are running at 60Hz, the gamepad inputs should also be sampled at that rate."

An analogue stick produces a new value continuously. There is no discrete moment of change to fire an event on — or rather, there are thousands of them per second, and firing an event for each would mean queueing work on the main thread that your render loop is going to ignore anyway because it only draws 60 times a second. Polling collapses that to one read per frame with the freshest possible value. This is the opposite of the Web MIDI model, which is genuinely event-driven because MIDI messages are discrete. Reconciling those two models is most of the work in building a browser bridge.

Why is getGamepads() returning an empty list?

Because no "gamepad user gesture" has happened yet, and until one does the spec requires the browser to expose nothing.

The editor's draft is explicit: before a gamepad user gesture has been seen, getGamepads() returns an empty list, and once one occurs, all gamepads become exposed. A gamepad user gesture means the user physically interacted with a supported input on the device, with the page visible, after the gamepad reported neutral values. MDN notes the same behaviour for Firefox and gives the reason — fingerprinting. A silent list of attached controllers is a surprisingly good device signature.

The spec goes further on that theme: the fingerprinting mitigation section says a user agent MAY alter the exposed device information, and gives the example of forcing a Gamepad object to report exactly the number of buttons and axes in the Standard Gamepad layout even when the physical device has more or fewer. That is worth internalising — buttons.length is not a reliable description of the hardware.

The practical consequence for your UI is that "no controller detected" is almost always wrong as a first message. The right message is "press any button on your controller".

// Correct empty-state handling for the gesture gate.
function gamepadsPresent() {
  // getGamepads() returns an array with null holes, not a dense list.
  return Array.from(navigator.getGamepads()).some(Boolean);
}

function renderEmptyState() {
  const hint = gamepadsPresent()
    ? 'Controller found.'
    : 'Press any button on your controller to wake it up.';
  document.getElementById('status').textContent = hint;
}

window.addEventListener('gamepadconnected', (e) => {
  console.log('index %d: %s (%d buttons, %d axes, mapping=%s)',
    e.gamepad.index, e.gamepad.id,
    e.gamepad.buttons.length, e.gamepad.axes.length, e.gamepad.mapping);
  renderEmptyState();
});

window.addEventListener('gamepaddisconnected', (e) => {
  console.log('gone: index %d (%s)', e.gamepad.index, e.gamepad.id);
  renderEmptyState();
});

One subtlety in the connected event: it fires both when a new gamepad is connected and when an already-connected gamepad first receives user input. So on a page load with a pad already plugged in, you will get the event on the first button press, not on load. Treat it as "a gamepad became usable", not "a gamepad was plugged in".

What exactly does a Gamepad object contain?

Eight attributes, all read-only, describing one snapshot in time.

AttributeTypeMeaning
idstringBrand or style identification, deliberately without unique device identifiers.
indexnumberSlot in the navigator's list, assigned first-come-first-served from zero.
connectedbooleanWhether the physical device is still attached.
timestampDOMHighResTimeStampWhen the system last received new axis or button data. At least 5 microsecond resolution.
mappingstringEmpty string, "standard", or "xr-standard".
axesfrozen arrayNormalised values in the range -1 to 1.
buttonsfrozen arrayGamepadButton objects, each with pressed, touched and value.
vibrationActuatorobject or nullHaptics. Part of the Gamepad Extensions work, not core.

timestamp is the most useful attribute nobody uses. The spec says it is set to the current high resolution time each time the system receives new input values from the hardware. If it has not moved since your last poll, nothing changed and you can skip the entire diff. On a quiet frame that saves you walking 17 buttons and 4 axes for nothing.

GamepadButton deserves a sentence of its own. It is not a boolean. pressed is the digital state, value is analogue from 0 to 1 — which is how triggers report — and touched indicates capacitive contact without a press on hardware that supports it. Treating every button as a boolean throws away trigger resolution, which is exactly the resolution you want for expression. The physical behaviour of those triggers on a DualSense is covered in the adaptive triggers deep dive.

What is the standard gamepad mapping?

A canonical 17-button, 4-axis layout that browsers remap recognised controllers onto, so your index-based code works across hardware.

When gamepad.mapping === 'standard', the browser has recognised the device and reordered its inputs to match the remapping table in the spec. Note that the spec names positions, not brand labels, precisely because the labels differ: button 0 is "bottom button in right cluster", which is Cross on a DualSense, A on an Xbox pad, and B on a Switch Pro controller in Nintendo's own layout.

IndexSpec locationDualSenseXbox
0Bottom button in right clusterCrossA
1Right button in right clusterCircleB
2Left button in right clusterSquareX
3Top button in right clusterTriangleY
4Top left front buttonL1LB
5Top right front buttonR1RB
6Bottom left front buttonL2 (analogue)LT (analogue)
7Bottom right front buttonR2 (analogue)RT (analogue)
8Left button in center clusterCreateView
9Right button in center clusterOptionsMenu
10Left stick pressedL3LS
11Right stick pressedR3RS
12Top button in left clusterD-pad upD-pad up
13Bottom button in left clusterD-pad downD-pad down
14Left button in left clusterD-pad leftD-pad left
15Right button in left clusterD-pad rightD-pad right
16Center button in center clusterPS buttonGuide

The four axes are fixed too: 0 is left stick horizontal, 1 is left stick vertical, 2 is right stick horizontal, 3 is right stick vertical. Both verticals are negative up, positive down, which catches everyone at least once. If your pitch bend goes the wrong way when you push the stick forward, that is why — negate axis 1 and 3 before you use them musically.

When mapping is the empty string, you are on your own: the indices are whatever the device's HID report descriptor produced, and you need a per-device map. Arcade sticks, flight sticks, dance mats and most third-party pads land here. That is the case where WebHID or a native bridge earns its keep.

How do you write the polling loop properly?

Poll in requestAnimationFrame, re-read the array every frame, skip the frame if the timestamp has not advanced, and diff before you emit.

The spec's own usage example is a requestAnimationFrame loop calling navigator.getGamepads(), and MDN reinforces the critical rule: read the current object for a known index each frame rather than holding a long-lived reference from an earlier event. Here is the complete version with all four rules applied.

// gamepad-poll.js - a correct, reusable polling loop.

const DEADZONE = 0.08;     // stick noise floor
const AXIS_STEPS = 128;    // quantise to the resolution you actually need

function applyDeadzone(v, dz = DEADZONE) {
  if (Math.abs(v) < dz) return 0;
  // Rescale so the value still reaches 1.0 at full deflection.
  const sign = v < 0 ? -1 : 1;
  return sign * ((Math.abs(v) - dz) / (1 - dz));
}

function quantise(v, steps = AXIS_STEPS) {
  // -1..1 -> 0..steps-1, which is what a 7-bit MIDI consumer wants.
  return Math.round(((v + 1) / 2) * (steps - 1));
}

function createPoller(onChange) {
  const prev = new Map();   // index -> { axes: number[], buttons: number[], ts: number }

  function frame() {
    const pads = navigator.getGamepads();   // fresh snapshots, every frame

    for (const pad of pads) {
      if (!pad || !pad.connected) continue;

      const last = prev.get(pad.index);
      if (last && pad.timestamp === last.ts) continue;   // nothing new

      const axes = pad.axes.map((a) => quantise(applyDeadzone(a)));
      const buttons = pad.buttons.map((b) => Math.round(b.value * 127));

      if (!last) {
        prev.set(pad.index, { axes, buttons, ts: pad.timestamp });
      } else {
        for (let i = 0; i < axes.length; i++) {
          if (axes[i] !== last.axes[i]) {
            onChange({ pad: pad.index, kind: 'axis', i, value: axes[i] });
          }
        }
        for (let i = 0; i < buttons.length; i++) {
          if (buttons[i] !== last.buttons[i]) {
            onChange({ pad: pad.index, kind: 'button', i, value: buttons[i] });
          }
        }
        last.axes = axes;
        last.buttons = buttons;
        last.ts = pad.timestamp;
      }
    }

    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

createPoller((e) => console.log(e.kind, e.i, e.value));

Four things that code gets right and most tutorials get wrong. It re-reads navigator.getGamepads() every frame rather than caching a pad. It skips work when timestamp has not moved. It quantises before comparing, so a stick jittering in the eighth decimal place does not count as a change. And it rescales after the deadzone so the stick still reaches full value at full deflection, instead of clipping the top of your range off.

Why does quantising before diffing matter so much?

Because an analogue axis is never still, and a naive float comparison will report a change on every single frame of every single stick, forever.

A resting thumbstick reports a different float almost every poll. If you diff raw floats you get 60 "changes" per second per axis per pad with nobody touching anything. Push that into a MIDI stream and you have saturated the port with meaningless CC traffic before you have played a note. Quantise to the consumer's actual resolution first — 128 steps for MIDI 1.0 CC, 16384 for a 14-bit pair — and then compare. Noise below one step disappears.

The matching hardware problem is stick drift, where the rest position is permanently off-centre rather than merely noisy. A deadzone hides it; calibration fixes it. Stick drift compensation covers the difference, and Hall-effect sticks covers avoiding it in hardware.

How fast can you actually poll?

As fast as your loop runs, but the device's own report rate is the real ceiling and polling past it returns duplicate data.

requestAnimationFrame fires at display refresh — 60 Hz on most panels, 120 Hz on high-refresh and ProMotion displays. You can poll faster with setInterval, but you will not get more information than the controller sends, and the timestamp check in the loop above will simply skip those extra frames. The honest answer is that the Gamepad API's resolution is bounded by the USB or Bluetooth report interval of the device, not by your JavaScript.

What you can control is whether your loop runs at all. requestAnimationFrame does not fire in a hidden tab — that is documented behaviour of rAF and it interacts with the Page Visibility API. Switch tabs mid-performance and your controller goes dead. There is no flag to turn that off, and it is one of the two or three reasons a serious rig ends up native; browser versus native walks through the rest.

// At minimum, tell the user when the loop has stopped.
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    panicAllNotesOff();                      // do not leave notes hanging
    setStatus('Paused - tab is in the background.');
  } else {
    setStatus('Live.');
  }
});

Can you make the controller rumble?

In Chromium and Firefox, yes, via gamepad.vibrationActuator.playEffect() — but it is an extension to the core spec, so feature-detect it.

The haptics work lives in the Gamepad Extensions draft alongside touch surfaces and pose data. MDN documents playEffect with the "dual-rumble" effect type and a parameters object carrying duration, start delay, and strong and weak magnitudes between 0 and 1.

async function pulse(pad, { strong = 0.6, weak = 0.2, ms = 120 } = {}) {
  const act = pad.vibrationActuator;
  if (!act || typeof act.playEffect !== 'function') return false;
  try {
    await act.playEffect('dual-rumble', {
      startDelay: 0,
      duration: ms,
      strongMagnitude: strong,
      weakMagnitude: weak
    });
    return true;
  } catch {
    return false;   // effect type unsupported on this device
  }
}

// Haptic click on every beat, driven by incoming MIDI clock.
function onMidiClockBeat(padIndex) {
  const pad = navigator.getGamepads()[padIndex];
  if (pad) pulse(pad, { strong: 0.35, weak: 0, ms: 40 });
}

Rumble from the browser is coarse compared to what the DualSense's actuators can actually do — the fine-grained adaptive trigger resistance is not reachable through the Gamepad API at all, only through raw HID or a native driver. MIDI feedback to rumble and adaptive triggers covers what the full surface looks like.

What does the Gamepad API not give you?

Gyroscope data, touchpad coordinates, battery level, LED control, per-device identifiers and anything the browser did not bother to normalise.

A DualSense has a six-axis IMU, a two-finger capacitive touchpad, a programmable light bar, a microphone and two actuator-driven triggers. The Gamepad API exposes four axes, seventeen buttons and a coarse rumble channel. The touches attribute exists in the spec for touch surfaces, but support is thin in practice. Everything else is invisible.

If you need those inputs in a browser, the only route is WebHID, where you parse the raw input report yourself — Chromium desktop only, and you are writing a device-specific parser. If you need them across every application on the machine rather than in one tab, that is a native driver's job, and it is what Joy-Con gyro motion control and the touchpad XY pad describe.

FAQ

Why does the Gamepad API have no button events?

Because it was designed around a snapshot model for games, which already run a render loop. The spec says gamepad data should be polled as closely as possible before the animation callbacks run, at the same frequency as the animation, so events would be redundant and would add queueing latency.

Why does navigator.getGamepads() return an empty list?

Because no gamepad user gesture has been seen yet. The specification requires the user to interact with a gamepad while the page is visible before any device is exposed, as a fingerprinting mitigation. Press a face button and poll again.

Can I keep a reference to a Gamepad object?

No. getGamepads returns snapshot objects and MDN explicitly advises reading the current object for a known index each frame rather than holding a long-lived reference from an earlier event. A held reference stops updating.

What is the standard gamepad mapping?

It is the canonical 17-button, 4-axis layout defined in the W3C Gamepad specification. When a browser recognises a device it sets mapping to "standard" and reorders inputs to match that layout, so button 0 is always the bottom button of the right cluster.

How fast can the Gamepad API poll?

As fast as your loop runs, but the underlying device sets the real ceiling. requestAnimationFrame fires at display refresh rate, typically 60 Hz, and polling faster than the device reports simply returns the same values with a newer timestamp.

What does the Gamepad timestamp attribute tell me?

It is set to the current high resolution time each time the system receives new button or axis values from the hardware, with at least 5 microsecond resolution. If the timestamp has not advanced, nothing has changed since your last poll.

Does the Gamepad API work when the tab is in the background?

Not reliably. requestAnimationFrame does not run in a hidden tab, so a rAF-driven poll loop stops. That is a deliberate platform behaviour, not a bug, and it is one of the main reasons live performance rigs use a native bridge instead.

Can I make the controller rumble from the browser?

In Chromium and Firefox, yes, through the non-standard vibrationActuator property and its playEffect method with a dual-rumble effect. It is part of the Gamepad Extensions work, not the core specification, so feature-detect it.

Put the loop to work

The polling loop above is the whole foundation. Bolt Web MIDI onto its onChange callback and you have a controller bridge; bolt Web Audio onto it and you have an instrument. Both are built step by step in the browser bridge guide, and you can watch the MIDI side of it running live at /play.

When the tab-visibility ceiling starts costing you takes, move the same loop into a process that never sleeps. Universal Controller MIDI polls the controller natively on macOS, Windows and Linux and publishes a real virtual MIDI port every application can see — free for one controller, one port and three saved presets, AUD $89 one-time for Pro.

Keep reading

More setup walkthroughs