Hands-on: real-time face tracking in the browser, no build tools
Face tracking used to require a C++ pipeline and a GPU budget. Today it runs at 60 fps in a browser tab, and you can build it in one HTML file with zero build tooling. This is the exact no-build pattern we use for production web experiences — ES modules straight from a CDN, no npm, no bundler. By the end you’ll have a webcam view with a live 478-point face mesh drawn over it.
Everything below goes in a single index.html. Serve it with any static
server (python3 -m http.server) — camera access requires https:// or
localhost, so double-clicking the file won’t work.
1. The skeleton
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Face tracking, no build tools</title>
<style>
body { margin: 0; background: #0a1214; color: #e8f0f0;
font-family: system-ui; display: grid; place-items: center;
min-height: 100vh; }
.stage { position: relative; }
video, canvas { width: 640px; max-width: 90vw; border-radius: 12px; }
canvas { position: absolute; inset: 0; }
button { padding: 0.8em 1.6em; font-size: 1rem; border-radius: 8px;
border: none; background: #2dd4bf; color: #062a26; }
</style>
</head>
<body>
<button id="start">Enable camera</button>
<div class="stage" hidden>
<video id="video" autoplay playsinline muted></video>
<canvas id="overlay"></canvas>
</div>
<script type="module" src="./tracker.js"></script>
</body>
</html>
Two details matter here. playsinline stops iOS from hijacking the video into
fullscreen. And the camera starts from a button click, not on page load —
browsers punish premature getUserMedia calls with permission prompts users
reflexively deny. Ask when the user has expressed intent.
2. Load MediaPipe from a CDN
In tracker.js. No npm install — the module comes from the CDN at runtime:
import { FaceLandmarker, FilesetResolver } from
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10/vision_bundle.mjs';
const filesets = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10/wasm'
);
const landmarker = await FaceLandmarker.createFromOptions(filesets, {
baseOptions: {
modelAssetPath:
'https://storage.googleapis.com/mediapipe-models/face_landmarker/' +
'face_landmarker/float16/1/face_landmarker.task',
delegate: 'GPU',
},
runningMode: 'VIDEO',
numFaces: 1,
});
delegate: 'GPU' runs inference on WebGL/WebGPU where available and falls
back to WASM on CPU. The float16 model is ~3.7 MB — cached after first load.
3. The camera permission dance
const video = document.getElementById('video');
const button = document.getElementById('start');
button.addEventListener('click', async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1280 } },
audio: false,
});
video.srcObject = stream;
button.remove();
document.querySelector('.stage').hidden = false;
video.addEventListener('loadeddata', startTracking, { once: true });
} catch (err) {
button.textContent =
err.name === 'NotAllowedError'
? 'Camera blocked — check site permissions'
: 'No camera found';
}
});
Handle NotAllowedError explicitly. A meaningful message when permission is
denied is the difference between a user fixing it and a user leaving.
4. Track and draw
The naive loop calls the model inside requestAnimationFrame. Better:
requestVideoFrameCallback, which fires exactly once per camera frame — no
wasted inference when the display refreshes faster than the webcam delivers.
function startTracking() {
const canvas = document.getElementById('overlay');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
const onFrame = (now, _meta) => {
const result = landmarker.detectForVideo(video, now);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const face of result.faceLandmarks ?? []) {
ctx.fillStyle = '#2dd4bf';
for (const p of face) {
ctx.fillRect(p.x * canvas.width, p.y * canvas.height, 2, 2);
}
}
video.requestVideoFrameCallback(onFrame);
};
video.requestVideoFrameCallback(onFrame);
}
Landmarks arrive normalized (0–1), so multiply by canvas dimensions. That’s the whole tracker: 478 points per face, per frame.
5. Performance notes from the field
Numbers from a mid-range Android phone (Pixel 7a class), Chrome:
- GPU delegate: ~8–11 ms inference per frame — comfortable 60 fps.
- WASM/CPU fallback: ~35–50 ms — cap your loop or the UI jellifies.
- Requesting 1280px capture but rendering at 640px halves compositing cost with no visible quality loss for point overlays.
- Thermals are real: after ~4 minutes of sustained GPU inference, expect
throttling. Production systems drop to every-other-frame tracking with
interpolation when
inference time > budgetfor consecutive frames.
6. Where it breaks
Honest failure modes you’ll hit immediately: profile views beyond ~60° lose
the mesh; low light collapses landmark stability (jitter, not absence —
harder to detect); two overlapping faces confuse numFaces: 1 tracking;
and heavy occlusion (hands, cups, phones) produces confidently wrong
landmarks rather than none.
Production systems handle these with temporal smoothing (One Euro filters), confidence gating, and explicit “tracking lost” UX states. Those are posts of their own — but the 80-line file you just built is the honest core of systems this practice has shipped to millions of users. The gap between this tutorial and those systems is exactly the gap consulting exists to cross.