Parsing EXIF Data From JPEG Files in the Browser With Zero Dependencies

Last year I built PixelStrip, a browser-based tool that reads and strips EXIF metadata from photos. When I started, I assumed I’d pull in exifr or piexifjs and call it a day. Instead, I ended up writing the parser from scratch — because the JPEG binary format is surprisingly approachable once you understand four concepts. Here’s everything I learned.

Why Parse EXIF Data in the Browser?

Server-side EXIF parsing is trivial — exiftool handles everything. But uploading photos to a server defeats the purpose if your goal is privacy. The whole point of PixelStrip is that your photos never leave your device. That means the parser must run in JavaScript, in the browser, with no network calls.

Libraries like exif-js (2.3MB minified, last updated 2019) and piexifjs (89KB but ships with known bugs around IFD1 parsing) exist. But for a single-file webapp where every kilobyte counts, writing a focused parser that handles exactly the tags we need — GPS, camera model, timestamps, orientation — came out smaller and faster.

JPEG File Structure: The 60-Second Version

A JPEG file is a sequence of markers. Each marker starts with 0xFF followed by a marker type byte. The ones that matter for EXIF:

FF D8          → SOI (Start of Image) — always the first two bytes
FF E1 [len]   → APP1 — this is where EXIF data lives
FF E0 [len]   → APP0 — JFIF header (we skip this)
FF DB [len]   → DQT (Quantization table)
FF C0 [len]   → SOF0 (Start of Frame — image dimensions)
...
FF D9          → EOI (End of Image)

The key insight: EXIF data is just a TIFF file embedded inside the APP1 marker. Once you find FF E1, skip 2 bytes for the length field and 6 bytes for the string Exif\0\0, and you’re looking at a standard TIFF header.

Step 1: Find the APP1 Marker

Here’s how to locate it. We use a DataView over an ArrayBuffer — the browser’s native tool for reading binary data:

function findAPP1(buffer) {
  const view = new DataView(buffer);

  // Verify JPEG magic bytes
  if (view.getUint16(0) !== 0xFFD8) {
    throw new Error('Not a JPEG file');
  }

  let offset = 2;
  while (offset < view.byteLength - 1) {
    const marker = view.getUint16(offset);

    if (marker === 0xFFE1) {
      // Found APP1 — return offset past the marker
      return offset + 2;
    }

    if ((marker & 0xFF00) !== 0xFF00) {
      break; // Not a valid marker, bail
    }

    // Skip to next marker: 2 bytes marker + length field value
    const segLen = view.getUint16(offset + 2);
    offset += 2 + segLen;
  }

  return -1; // No EXIF found
}

This runs in under 0.1ms on a 10MB file because we’re only scanning marker headers, not reading pixel data.

Step 2: Parse the TIFF Header

Inside APP1, after the Exif\0\0 prefix, you hit a TIFF header. The first two bytes tell you the byte order:

  • 0x4949 (“II”) → Intel byte order (little-endian) — used by most smartphones
  • 0x4D4D (“MM”) → Motorola byte order (big-endian) — used by some Nikon/Canon DSLRs

This is the gotcha that trips up every first-time EXIF parser writer. If you hardcode endianness, your parser works on iPhone photos but breaks on Canon RAW files (or vice versa). You must pass the littleEndian flag to every DataView call:

function parseTIFFHeader(view, tiffStart) {
  const byteOrder = view.getUint16(tiffStart);
  const littleEndian = byteOrder === 0x4949;

  // Verify TIFF magic number (42)
  const magic = view.getUint16(tiffStart + 2, littleEndian);
  if (magic !== 0x002A) {
    throw new Error('Invalid TIFF header');
  }

  // Offset to first IFD, relative to TIFF start
  const ifdOffset = view.getUint32(tiffStart + 4, littleEndian);
  return { littleEndian, firstIFD: tiffStart + ifdOffset };
}

Step 3: Walk the IFD (Image File Directory)

An IFD is just a flat array of 12-byte entries. Each entry has:

📚 Continue Reading

Sign in with your Google or Facebook account to read the full article.
It takes just 2 seconds!

Already have an account? Log in here

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *