The Gamepad API is one of the smallest browser APIs you’ll ever use. Four properties, one function, and no permissions prompt. You can have a controller drawn on screen in about fifteen lines.

Those fifteen lines will also quietly report that a broken controller is fine.

I found this out the slow way, building a browser-based controller tester. A user emailed to say the site told him his gamepad was healthy when the stick was visibly drifting in every game he owned. He was right. The browser had handed us zeros.

This article covers the parts of the Gamepad API that aren’t in the spec docs and that cost me real debugging time: why you have to poll, why the values you get on page load aren’t the values the hardware sent, why you can’t tell what controller is plugged in, and how to tell a drifting analog stick apart from a person holding one.

All the code here runs in a browser console with a controller connected. Press a button first, or the API will pretend nothing is plugged in.

Table of Contents

Prerequisites

This is a hands-on guide. There’s nothing to install and no build step, but a few things need to be true before the code below will do anything.

What you should already know:

  • JavaScript at a working level: functions, arrays and array methods like reduce and filter, arrow functions, and destructuring.
  • What an animation frame loop is. Several of the examples run inside requestAnimationFrame.
  • How to open your browser’s developer tools and paste code into the console.

One section does a little vector arithmetic: the mean of a set of x and y samples and the length of that mean vector. If Math.hypot(x, y) makes sense to you, that section will too.

What you need to have:

  • A desktop browser that supports the Gamepad API. Chrome, Edge, Firefox, and Safari have all supported it since 2017, so whatever you have open is almost certainly fine.
  • A physical game controller, connected by USB or Bluetooth. There’s no way to fake one in software, and none of the code below does anything useful without hardware attached.
  • Ideally, a controller you know is faulty, like one with stick drift if you have it. Several of the behaviours in this article only show up on broken hardware. A healthy controller will hide them from you.

You don’t need any frameworks or libraries, or npm install. Every block below is plain JavaScript that runs as written.

The Tester That Doesn’t Work

Here’s the version almost everyone writes first. It’s the version in most tutorials.

window.addEventListener("gamepadconnected", (e) => {
  const pad = navigator.getGamepads()[e.gamepad.index];
  console.log(pad.axes);    // [0, 0, 0, 0]
  console.log(pad.buttons.filter(b => b.pressed).length);   // 0
});

Plug in a controller with severe stick drift — one that pulls a character across the screen on its own in every game — and this prints [0, 0, 0, 0].

There are two separate bugs in those five lines, and the second one is the interesting one.

Why You Have to Poll

The first bug is that there are no input events. gamepadconnected and gamepaddisconnected fire, and that’s the entire event surface. There’s no gamepadaxischange and no gamepadbuttondown. If you want to know what the sticks are doing, you have to ask, over and over, usually in requestAnimationFrame.

The second part of the same bug: you have to call navigator.getGamepads() again every single frame. It returns snapshots. Holding on to a Gamepad object and reading it later gets you the values from the moment you grabbed it, frozen, forever.

function loop() {
  const pads = navigator.getGamepads();     // re-read every frame, do not cache
  for (const pad of pads) {
    if (!pad) continue;                     // the array has empty slots, always guard
    render(pad.index, pad.axes, pad.buttons);
  }
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Two practical notes on that loop.

First, the array is sparse. navigator.getGamepads() returns a fixed-length array with null in the slots that have nothing connected, so a plain for...of without the guard will throw on the first null.

Second, polling isn’t free. A requestAnimationFrame loop that starts at page load and runs forever is real main thread work on a page that may have no controller connected at all and never will.

Here’s a pattern that works well: idle at a low rate — something like 8 times a second with setTimeout — purely to notice a controller appearing, then switch to full requestAnimationFrame once one is actually connected, and drop back down when it disconnects. The API is cheap to sample, but sampling it 60 times a second on every page view for nothing is a waste you’ll see in a performance profile.

The Sanitization Rule

Now the part that is genuinely under-documented, and the reason the drifting controller reported zeros.

Chromium won’t report an axis’s real value until it has seen that axis at rest at least once.

Not until the user moves it. Until the browser observes it near zero.

The mechanism is in one file, device/gamepad/gamepad_pad_state_provider.cc. The browser keeps two bitfields per connected controller: an axis_mask and a button_mask. While an axis’s bit is unset, its reported value is forced to 0.0. The bit gets set the first time that axis reports a magnitude below a constant called kMinAxisResetValue, which is 0.1f. From then on, real values flow through.

Buttons work the same way through button_mask, with a stricter test: the bit is set the first time the button reports as not pressed. A button that’s held down as the page loads, or a trigger that a broken spring is holding halfway, reports pressed: false and value: 0 until the browser sees it released once.

This isn’t a bug, and it’s worth understanding why it’s there. The comment in the source explains it: a controller can report input when nobody is touching it, because of a hardware fault or because something heavy is leaning on a stick. Without this rule, that stray input would be treated as a user gesture, and the page would learn about a device the user never chose to reveal. So each axis and each button has to prove it can sit at rest before the browser will tell you anything about it.

Read the consequence carefully, because it’s the opposite of what you would guess:

The worse the drift, the longer the browser insists the controller is fine.

A stick with a small offset will pass under 0.1 on some frame soon enough and unmask itself. A badly worn stick that never settles back inside that window stays masked indefinitely. The controller that most needs reporting is the one that reports nothing.

This also explains something that looks like magic in controller testers. Instructions like “move both sticks in a full circle” don’t work because movement unlocks the axis. They work because a full circle passes through the centre on the way back.

Here is a demo you can paste into a console. Connect a controller, load the page, and don’t touch the sticks. Then push the left stick to the edge and let it spring back.

const start = performance.now();
let woke = false;

requestAnimationFrame(function loop() {
  const pad = navigator.getGamepads()[0];
  if (pad && !woke) {
    const [x, y] = pad.axes;
    if (x !== 0 || y !== 0) {
      woke = true;
      console.log(
        "left stick started reporting after",
        Math.round(performance.now() - start), "ms,",
        "first values:", x.toFixed(3), y.toFixed(3)
      );
    }
  }
  requestAnimationFrame(loop);
});

On a healthy controller sitting still, the axes unmask almost immediately, because a healthy stick rests at roughly zero. On a drifting one, nothing is logged until you send the stick through the centre yourself.

The practical rule that falls out of this: never draw a conclusion about hardware from the first frame after connection. Wait until you’ve seen each axis report a non-zero value at least once, or ask the user to move the sticks, and only then trust what you’re reading.

You Can’t Identify the Hardware, Either

The second surprise is smaller but it will bite you in the UI layer.

The spec gives you pad.id, a string the browser makes up. On Linux and often on macOS it contains a USB vendor and product ID in hex, and you can look the device up. On Windows, XInput devices — which is to say most Xbox-style controllers — expose no vendor or product ID at all. The string looks like "Xbox 360 Controller (XInput STANDARD GAMEPAD)", and a third-party clone reports exactly the same thing as first-party hardware.

macOS has its own version of this. A DualShock 4 connected to Chrome on macOS arrives as "Wireless Controller (STANDARD GAMEPAD)". No vendor ID, no product ID, and a name generic enough that half a dozen unrelated controllers share it.

That last one caused a real bug. Glyph rendering keyed off a parsed id string, so every DualShock 4 on a Mac fell through to the generic fallback and drew Xbox-style button labels on a PlayStation controller. The fix was to treat pad.id as a hint rather than a key, and to let users override the detected type manually when the automatic detection gets it wrong.

Telling Drift from a Human Hand

Once axes are unmasked and reporting real values, the next problem is deciding whether movement is intentional. A deadzone — discarding any value whose magnitude is below some threshold — is the standard answer, but a fixed deadzone set high enough to swallow drift will also eat legitimate small inputs. Set it too low and drift bleeds through.

A better approach is to characterize the resting state before the user touches anything. Collect a few hundred samples of each axis while the controller sits still, compute the mean offset, and subtract it before applying a tighter deadzone. A stick with a 0.15 drift bias becomes a stick centered near zero, and a 0.05 deadzone is enough to catch the remaining noise.

function collectBaseline(padIndex, durationMs = 2000) {
  return new Promise(resolve => {
    const samples = { 0: [], 1: [], 2: [], 3: [] };
    const end = performance.now() + durationMs;

    requestAnimationFrame(function sample() {
      const pad = navigator.getGamepads()[padIndex];
      if (pad) {
        pad.axes.forEach((v, i) => samples[i].push(v));
      }
      if (performance.now() < end) {
        requestAnimationFrame(sample);
      } else {
        const baseline = Object.fromEntries(
          Object.entries(samples).map(([i, vals]) => [
            i,
            vals.reduce((a, b) => a + b, 0) / vals.length
          ])
        );
        resolve(baseline);
      }
    });
  });
}

Call this at startup, before prompting the user to interact, and store the result. Then subtract baseline[axisIndex] from every raw reading before passing it to your deadzone logic. The number you subtract is the hardware’s resting error; everything left is signal.

This doesn’t help with controllers whose drift changes over time — a warm potentiometer reads differently than a cold one — but it catches the common case of a fixed offset and dramatically shrinks the deadzone you need.

Known Limits

A few things the API cannot do, regardless of how carefully you poll:

  • No force feedback on all platforms. The GamepadHapticActuator interface exists, but browser and OS support is inconsistent. Chrome on Windows supports it for XInput devices; support elsewhere varies.
  • No gyroscope or accelerometer data in the standard API. Some browsers expose motion data through vendor extensions, but nothing is cross-browser.
  • No way to distinguish connection type. You cannot tell from the API alone whether a controller is connected via USB or Bluetooth. Bluetooth controllers often have higher latency and more packet loss, but the API presents both identically.
  • The axis and button count is hardware-dependent. There’s no guarantee that axis 0 is a left stick or that button 0 is a face button unless the controller maps to the Standard Gamepad layout. Always check pad.mapping and branch on whether it equals "standard".

Wrapping Up

The Gamepad API’s surface area is small, but its failure modes are specific enough that they’re hard to reason about from the spec alone. The sanitization rule in particular inverts the intuition you’d bring from other input APIs: silence is not the same as healthy, and the inputs you most need to see are the ones the browser is most likely to be hiding. Building anything that depends on accurate controller state — a tester, a calibration tool, or a game that needs to handle hardware gracefully — requires polling on every frame, waiting for axes to unmask before drawing conclusions, treating pad.id as approximate, and measuring the resting state before applying any deadzone logic.