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: TestOnDevice — 118+ 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();
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());
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();
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
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:
- Reach for platform APIs before libraries —
getUserMedia, Gamepad, Web MIDI, Pointer Events and the sensor APIs cover an enormous amount of ground. - Treat stream cleanup as a first-class feature, not an afterthought.
- 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)
"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"
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
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.
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.
Good point about “privacy by architecture.” That’s much stronger than just saying “we respect your privacy.” Nice work.
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.
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.