DEV Community

Cover image for The camera and mic are just an API call away — capturing media in a PWA (FieldKit #3)
Oleksandr Trukhnii
Oleksandr Trukhnii

Posted on

The camera and mic are just an API call away — capturing media in a PWA (FieldKit #3)

This is part 3 of FieldKit, a series where I build one real Progressive Web App and use it to dig into what modern PWAs can actually do. FieldKit is a field-notes app — it's open source (on GitHub). So far it works offline and installs like a native app. Now we give it senses: capturing photos and voice notes straight from the device camera and microphone.

A field note is more than text

A trail note isn't just words. It's the photo of the fork in the path, the 10-second voice memo describing what you saw. So FieldKit needs to reach the camera and microphone — and here's the thing that still surprises people coming from native: on the web, that's a single API call.

No SDK, no plugin, no platform-specific permission plumbing. navigator.mediaDevices.getUserMedia() gives you a live media stream; MediaRecorder turns a stream into a file. In this part we'll:

  1. capture a photo with getUserMedia + a <canvas>,
  2. record audio with MediaRecorder,
  3. store both as Blobs in IndexedDB, and
  4. play them back in the feed.

And, as always, I'll be honest about where iOS makes you work for it.

Photos: a live stream painted onto a canvas

There's no "take a photo" API on the web. The technique is: open a video stream, show it as a preview, and when the user taps Capture, paint the current video frame onto a <canvas> and export that as an image Blob.

Opening the camera:

const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: "environment" }, // prefer the rear camera
  audio: false,
});

video.srcObject = stream;
await video.play();
Enter fullscreen mode Exit fullscreen mode

facingMode: "environment" asks for the rear camera (you'd use "user" for the selfie cam). It's a hint — a laptop with one webcam just gives you that.

When the user taps Capture, we snapshot the frame:

const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);

canvas.toBlob((blob) => resolve(blob), "image/jpeg", 0.85);
Enter fullscreen mode Exit fullscreen mode

toBlob gives us a compressed JPEG at 85% quality — good enough for a field photo, far smaller than a PNG. That Blob is the thing we'll store.

The gotcha that leaves your camera light on

Here's the mistake almost everyone makes the first time: they capture the photo and forget to stop the stream. The camera stays live — indicator light on, device marked "in use" — until the tab is closed. You must explicitly stop every track:

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

In FieldKit I wrap this in a cleanup() that runs whether the user captures or cancels, so the camera is always released:

const cleanup = () => {
  stream.getTracks().forEach((t) => t.stop()); // release the camera
  modal.hidden = true;
  snap.onclick = cancel.onclick = null;
};
Enter fullscreen mode Exit fullscreen mode

Treat the stream like an open file handle: if you opened it, you close it.

Audio: MediaRecorder does the heavy lifting

Recording audio is even less code, because MediaRecorder handles the encoding. Get an audio stream, feed it to a recorder, collect the chunks it emits:

recStream = await navigator.mediaDevices.getUserMedia({ audio: true });
recorder = new MediaRecorder(recStream, { mimeType });

const chunks = [];
recorder.ondataavailable = (e) => {
  if (e.data.size) chunks.push(e.data);
};
recorder.start();
Enter fullscreen mode Exit fullscreen mode

When the user stops, we assemble the chunks into one Blob and — again — release the mic:

recorder.onstop = () => {
  const blob = new Blob(chunks, { type: recorder.mimeType });
  recStream.getTracks().forEach((t) => t.stop()); // release the mic
  resolve(blob);
};
recorder.stop();
Enter fullscreen mode Exit fullscreen mode

Don't hard-code the codec

This one bites people in production. Chromium records audio/webm with Opus; Safari doesn't support WebM at all and produces audio/mp4. If you hard-code "audio/webm" your recorder throws on Safari. So ask the browser what it can actually produce and pick the first supported option:

function pickAudioMime() {
  const candidates = [
    "audio/webm;codecs=opus", // Chromium/Firefox
    "audio/webm",
    "audio/mp4",              // Safari
    "audio/aac",
  ];
  return candidates.find((t) => MediaRecorder.isTypeSupported?.(t)) || "";
}
Enter fullscreen mode Exit fullscreen mode

Passing "" (or omitting mimeType) lets the browser fall back to its own default — safer than guessing wrong.

Storing and playing back: Blobs + object URLs

Two nice facts make persistence trivial. First, IndexedDB stores Blobs natively — no base64 encoding, no serialization. The media just rides along on the entry we already save (from part 1):

const entry = {
  id: crypto.randomUUID(),
  text,
  media: pendingMedia, // { type, blob } | null — Blobs live happily in IndexedDB
  createdAt: Date.now(),
  synced: false,
};
Enter fullscreen mode Exit fullscreen mode

Second, to display a Blob you turn it into a temporary URL with URL.createObjectURL() and hand that to an <img> or <audio>:

function renderMedia(box, media) {
  const url = URL.createObjectURL(media.blob);
  objectUrls.push(url);
  if (media.type === "image") {
    const img = document.createElement("img");
    img.src = url;
    img.alt = "Attached photo";
    box.appendChild(img);
  } else if (media.type === "audio") {
    const audio = document.createElement("audio");
    audio.controls = true;
    audio.src = url;
    box.appendChild(audio);
  }
}
Enter fullscreen mode Exit fullscreen mode

The invisible memory leak

URL.createObjectURL() pins the Blob in memory until you explicitly release the URL. Re-render your feed a few times and you've quietly leaked every photo you ever showed. The fix is to revoke the previous batch before creating new ones:

async function render() {
  objectUrls.forEach(URL.revokeObjectURL); // free last render's URLs
  objectUrls = [];
  // …create fresh URLs for what's currently on screen…
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of thing that never shows up in a demo and absolutely shows up in a long-running app. Worth getting right early.

A note on video

I kept FieldKit to photos and audio, but video capture is the same MediaRecorder pattern as audio — you just record a stream that has a video track:

const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const recorder = new MediaRecorder(stream, { mimeType: "video/webm" }); // or video/mp4 on Safari
Enter fullscreen mode Exit fullscreen mode

Everything else — chunks, onstop, the Blob, IndexedDB, object-URL playback in a <video> — is identical. The reasons I left it out of the app are practical, and they're the honest trade-offs: video Blobs get big fast (watch your storage quota), and codec fragmentation is worse than audio. Same technique, higher stakes.

And there's a third sibling worth knowing about: getDisplayMedia() captures the screen (a display, window, or tab) instead of the camera, then flows into the same MediaRecorder pipeline. It doesn't belong in a field-notes app, so it's not in FieldKit — but it's the basis for screen recording and sharing on the web, and it has its own quirks (the browser owns the source picker, system-audio capture is limited, and iOS doesn't really support it). I'll give it a dedicated post rather than cram it in here.

Honest support picture

  • getUserMedia: supported across all modern browsers — but only in a secure context (HTTPS or localhost). Serve over plain HTTP and navigator.mediaDevices is undefined. Permission is per-origin and the user can deny or revoke it, so always handle NotAllowedError, NotFoundError, and NotReadableError (device busy).
  • MediaRecorder: widely supported now, including Safari (since 14.1) — but the output format differs by browser, which is why codec detection isn't optional.
  • iOS quirks: getUserMedia only works in Safari and browsers using its engine; a <video> preview needs the playsinline attribute or iOS hijacks it into fullscreen; and autoplaying media generally needs to be muted. FieldKit's preview element is <video playsinline muted> for exactly this reason.
  • canvas.toBlob: universal. image/jpeg and image/png everywhere; image/webp is Chromium/Firefox.

Check caniuse: getUserMedia and MediaRecorder before relying on specifics.

How this compares to Electron

  • Electron reaches the camera and mic through the same web APIs — getUserMedia, MediaRecorder — because it's Chromium under the hood. The difference is permissions and reach: Electron can grant media access at the app level (or bypass prompts via its main process) and, being desktop-only with Node access, it can write big video files straight to disk with fs. It never has to worry about a Safari codec or an iOS playsinline attribute, because there's only one engine.
  • The PWA gives you the identical capture code that also runs on a phone the user carries into the field — no separate mobile app. The price is the cross-browser reality: you detect codecs, you handle permission denials gracefully, you respect storage quotas instead of assuming infinite disk.

The capture logic you write for FieldKit would drop almost unchanged into an Electron app. That's the quiet superpower here — the web media stack is the Electron media stack, minus the 100 MB runtime and plus a phone in your pocket.

Try it

Serve FieldKit over localhost, tap the 📷 or 🎙️ button, grant permission, and attach a photo or voice note to an entry.

Photo and voice note

It's stored locally (works offline, naturally) and plays back right in the feed.

git clone https://github.com/JohnJunior/FieldKit.git
cd FieldKit
npx serve .
Enter fullscreen mode Exit fullscreen mode

Next up (FieldKit #4): location & sensors — geotagging each note with the Geolocation API and reading device orientation, so a field note knows where it was taken and which way you were facing.

Top comments (0)