← Blog

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/[email protected]/vision_bundle.mjs';

const filesets = await FilesetResolver.forVisionTasks(
  'https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/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 > budget for 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.