DEV Community

Cover image for I built 118+ device tests that run entirely in your browser — no server, no uploads
KAAN TOKALI
KAAN TOKALI

Posted on • Originally published at testondevice.com

I built 118+ device tests that run entirely in your browser — no server, no uploads

Every time I needed to check whether my microphone actually worked before a call, or whether a key on a used keyboard was dead, I ended up on some sketchy "free online tester" that wanted an account, showed six ad banners, and — the worst part — happily streamed my webcam to a server I knew nothing about.

So I built the thing I actually wanted: TestOnDevice118+ device tests that run 100% in the browser. No installs, no account, and nothing you record ever leaves your machine.

Here's what I learned building it.

The core constraint: the network is off-limits

The single rule that shaped the whole project: no device data touches a server. No webcam frames, no audio buffers, no keystrokes, no sensor readings.

That constraint turned out to be freeing rather than limiting, because modern browser APIs are genuinely powerful. Almost every "device test" is just a Web API plus a bit of rendering:

What you're testing The API doing the work
Microphone / speakers MediaDevices.getUserMedia, Web Audio (AnalyserNode)
Webcam getUserMedia, <video>, MediaStreamTrack.getSettings()
Keyboard keydown / keyup, KeyboardEvent.code
Mouse / touch / stylus Pointer Events, PointerEvent.pressure
Gamepads Gamepad API
MIDI keyboards Web MIDI API
Display (dead pixels, refresh rate) Fullscreen + requestAnimationFrame
Motion / orientation DeviceOrientationEvent, DeviceMotionEvent

A live mic meter, entirely local

The microphone test is a good example of how little you need. Grab a stream, wire it into an AnalyserNode, and compute RMS on each frame for a level meter — no recording, no upload:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
ctx.createMediaStreamSource(stream).connect(analyser);

const data = new Uint8Array(analyser.frequencyBinCount);

function tick() {
  analyser.getByteTimeDomainData(data);
  let sum = 0;
  for (const v of data) {
    const x = (v - 128) / 128;
    sum += x * x;
  }
  drawMeter(Math.sqrt(sum / data.length)); // RMS -> level bar
  requestAnimationFrame(tick);
}
tick();
Enter fullscreen mode Exit fullscreen mode

The most important line, though, is the cleanup. The moment the user stops or leaves, the hardware indicator light should go off:

stream.getTracks().forEach((track) => track.stop());
Enter fullscreen mode Exit fullscreen mode

Releasing tracks aggressively is what makes a diagnostics tool feel trustworthy instead of creepy.

Gamepads: no events, only polling

The Gamepad API surprised me. You get a gamepadconnected event, but after that there are no button events at all — you have to poll the current state every frame:

window.addEventListener("gamepadconnected", (e) => {
  console.log("connected:", e.gamepad.id);
});

function poll() {
  for (const pad of navigator.getGamepads()) {
    if (!pad) continue;
    pad.buttons.forEach((b, i) => b.pressed && highlight(i));
    // Analog sticks: pad.axes[0], pad.axes[1], ...
  }
  requestAnimationFrame(poll);
}
poll();
Enter fullscreen mode Exit fullscreen mode

That polling model is exactly why it's great for detecting stick drift: read pad.axes while the sticks are untouched and watch whether the values sit at zero or quietly wander. If you want to see it raw, the controller input viewer is just a live JSON dump of every button and axis.

The permissions gotcha that bites everyone

enumerateDevices() will happily list your cameras and microphones before you grant permission — but the label field is an empty string until you do:

const devices = await navigator.mediaDevices.enumerateDevices();
// device.label is "" until the user grants permission to that kind of device
Enter fullscreen mode Exit fullscreen mode

This is a deliberate anti-fingerprinting measure. The UX fix is simple: show generic entries ("Microphone 1", "Camera 1") first, then re-enumerate and reveal real names after the user opts in. I ended up building a whole device enumerator around exactly this behavior.

Privacy as an architecture, not a promise

"We respect your privacy" in a footer is worthless. What actually earns trust:

  • No backend for device data. There's no endpoint to upload frames to, so there's nothing to leak.
  • Late permission requests. Access is only ever asked for after an explicit click on that specific test.
  • Immediate teardown. Streams are released on stop and on page unload.
  • No account, no fingerprint. Nothing to correlate you across sessions.

Because there's no server round-trip, most tests also work as a PWA offline — handy on a fresh machine with no drivers yet installed.

Takeaways

If you're building anything hardware- or media-adjacent for the web:

  1. Reach for platform APIs before libraries — getUserMedia, Gamepad, Web MIDI, Pointer Events and the sensor APIs cover an enormous amount of ground.
  2. Treat stream cleanup as a first-class feature, not an afterthought.
  3. Let real privacy fall out of the architecture. "No server" is a stronger guarantee than any policy page.

You can try the whole thing here: testondevice.com. Start with the Quick Check if you just want a fast pass over everything, or browse all tests — mic, camera, keyboard, mouse, gamepad, MIDI, dead-pixel and refresh-rate — all running locally in your tab.

If you want a deep dive into any single one (the refresh-rate detector and the dead-pixel finder both have some fun edge cases), let me know in the comments and I'll write it up.

Top comments (7)

Collapse
 
frank_signorini profile image
Frank

"I've been experimenting with similar browser-based diagnostics, but I'm curious how you handled audio and video input without relying on a server-side solution. Could you elaborate on the tech stack you used for those tests? I'm following you for more insights on browser-based testing"

Collapse
 
kaan_tokali_5a4828a3f897c profile image
KAAN TOKALI • Edited

Thanks Frank! There's really no server-side piece for the media — it's all client-side Web APIs. For audio I grab the stream with getUserMedia({ audio: true }) and run it through a Web Audio AnalyserNode for the live meter and the tone/frequency stuff. Video is the same getUserMedia piped into a

Collapse
 
merbayerp profile image
Mustafa ERBAY

Really like the “privacy by architecture” approach. One nuance I’d add is that “no backend” doesn’t completely eliminate fingerprinting concerns, since some browser APIs still expose characteristics that can contribute to passive fingerprinting. Not transmitting that data is a huge improvement, but it’s worth making that distinction. Also, a short section on browser compatibility (especially Safari/iOS) would make this an even stronger practical guide.

Collapse
 
kaan_tokali_5a4828a3f897c profile image
KAAN TOKALI

Good nuance, and you're right — "no backend" only kills the transmission risk, it doesn't make the browser un-fingerprintable. These APIs still leak device characteristics passively; the win is just that none of it gets collected or sent anywhere, not that the surface goes away. I should make that distinction clearer in the post. Same with compatibility — Safari/iOS has its own quirks around Web MIDI, autoplay and the gesture requirements, so I'll add a short browser-support section. Thanks, this is the kind of feedback that actually makes the writeup better.

Collapse
 
merbayerp profile image
Mustafa ERBAY

Good point about “privacy by architecture.” That’s much stronger than just saying “we respect your privacy.” Nice work.

Collapse
 
jianqiang_lou_46699ce6ff0 profile image
jianqiang lou

I think useful pages still win when the intent is specific enough. The tricky part is making the page actually solve the problem, not just target the keyword. People can feel that pretty fast.

Collapse
 
kaan_tokali_5a4828a3f897c profile image
KAAN TOKALI

Yeah, agreed. The keyword just gets someone to the page — after that it's on you to actually solve their problem fast, or they bounce. That's why I kept each test to one job and no signup wall. You can feel the difference right away, like you said. Thanks for reading.