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:
- capture a photo with
getUserMedia+ a<canvas>, - record audio with
MediaRecorder, - store both as Blobs in IndexedDB, and
- 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();
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);
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());
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;
};
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();
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();
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)) || "";
}
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,
};
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);
}
}
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…
}
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
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 orlocalhost). Serve over plain HTTP andnavigator.mediaDevicesisundefined. Permission is per-origin and the user can deny or revoke it, so always handleNotAllowedError,NotFoundError, andNotReadableError(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 theplaysinlineattribute or iOS hijacks it into fullscreen; and autoplaying media generally needs to bemuted. FieldKit's preview element is<video playsinline muted>for exactly this reason. -
canvas.toBlob: universal.image/jpegandimage/pngeverywhere;image/webpis 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 withfs. It never has to worry about a Safari codec or an iOSplaysinlineattribute, 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.
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 .
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)