DEV Community

Cover image for Parsing and Stripping EXIF Metadata Completely In-Browser with JavaScript ArrayBuffers
Ishan IND
Ishan IND

Posted on

Parsing and Stripping EXIF Metadata Completely In-Browser with JavaScript ArrayBuffers

Every smartphone photo you take embeds precise metadata inside the file: GPS latitude and longitude, camera serial numbers, lens focal lengths, and timestamps. When users look for an online tool to check or clean this data before sharing an image, 95% of web utilities ask them to upload their private photo to an external backend server.

From a privacy standpoint, uploading a sensitive photo to a remote server just to strip a few kilobytes of metadata completely defeats the purpose.

The common frontend alternative—loading the image into an HTML5 <canvas> and calling canvas.toBlob('image/jpeg')—is equally flawed. A canvas re-encodes the image, which introduces compression artifacts, alters the color profile, and blows away the original image quality.

While engineering ScanMetadata, we designed the parser and scrubber to run 100% client-side directly in the browser's memory using ArrayBuffer and DataView. Here is how JPEG binary markers work and how to strip EXIF data losslessly without touching a single pixel.


1. Anatomy of a JPEG: Where EXIF Actually Lives

A JPEG file is not a continuous block of pixels; it is organized into sequential binary blocks called segments, each starting with a two-byte marker (0xFF followed by a marker code):

  • 0xFFD8 — SOI (Start of Image): The first two bytes of every valid JPEG.
  • 0xFFE1 — APP1 Segment: Where EXIF metadata (camera specs, GPS coordinates, thumbnail) is stored.
  • 0xFFE0 — APP0 Segment: Standard JFIF header.
  • 0xFFDB — DQT: Quantization tables.
  • 0xFFC0 / 0xFFC2 — SOF: Frame header (image width, height, bit depth).
  • 0xFFDA — SOS (Start of Scan): The beginning of the actual compressed image payload.
  • 0xFFD9 — EOI (End of Image): The trailing two bytes marking the file termination.

Immediately following any segment marker (except SOI, EOI, and SOS), the next two bytes represent the length of that entire segment (including those two length bytes themselves, stored in Big-Endian format).


2. Reading Binary Markers with DataView

To inspect metadata safely without reading entire multi-megabyte files at once, we use the File API with a FileReader or file.arrayBuffer() slice and inspect it through a DataView:

export async function locateExifSegment(file) {
  // Read only the first 128KB to locate headers quickly
  const slice = await file.slice(0, 131072).arrayBuffer();
  const view = new DataView(slice);

  // Validate SOI Marker (0xFFD8)
  if (view.getUint16(0, false) !== 0xffd8) {
    throw new Error("Invalid JPEG: Missing SOI marker");
  }

  let offset = 2;
  const length = view.byteLength;

  while (offset < length - 4) {
    const marker = view.getUint16(offset, false);

    // Stop if we hit Start of Scan (SOS) - image data starts here
    if (marker === 0xffda || marker === 0xffd9) {
      break;
    }

    // 0xFFE1 is the APP1 / EXIF Marker
    if (marker === 0xffe1) {
      const segmentLength = view.getUint16(offset + 2, false);
      return {
        hasExif: true,
        startOffset: offset,
        totalLength: segmentLength + 2 // includes the 2-byte marker itself
      };
    }

    // Move to the next segment: marker (2 bytes) + segment length
    const segmentLength = view.getUint16(offset + 2, false);
    offset += 2 + segmentLength;
  }

  return { hasExif: false, startOffset: -1, totalLength: 0 };
}
Enter fullscreen mode Exit fullscreen mode

3. Parsing the TIFF Header and Endianness

Inside the 0xFFE1 segment, the payload starts with the ASCII string "Exif\0\0" (6 bytes), immediately followed by the TIFF header.

The first two bytes of the TIFF header indicate byte alignment (Endianness):

  • 0x4949 ("II"): Intel format (Little-Endian)
  • 0x4D4D ("MM"): Motorola format (Big-Endian)

Reading values with the wrong endianness corrupts the tag pointers and GPS coordinates. Here is how we extract whether the file uses Little-Endian:

export function inspectTiffHeader(view, exifStartOffset) {
  // Offset + 4 bytes (marker + length) + 6 bytes ("Exif\0\0")
  const tiffOffset = exifStartOffset + 10;
  const endianTag = view.getUint16(tiffOffset, false);

  let isLittleEndian;
  if (endianTag === 0x4949) {
    isLittleEndian = true; // Intel
  } else if (endianTag === 0x4d4d) {
    isLittleEndian = false; // Motorola
  } else {
    throw new Error("Invalid TIFF header format");
  }

  // The next 2 bytes must be the constant 42 (0x002A)
  const magic = view.getUint16(tiffOffset + 2, isLittleEndian);
  if (magic !== 42) {
    throw new Error("Malformed TIFF header");
  }

  // First Image File Directory (IFD0) offset
  const ifd0Offset = view.getUint32(tiffOffset + 4, isLittleEndian);
  return { tiffOffset, isLittleEndian, ifd0Offset };
}
Enter fullscreen mode Exit fullscreen mode

4. Lossless Stripping via Buffer Slicing (No Canvas)

Instead of passing the file into an HTML canvas and re-compressing the pixels, we can strip the metadata with zero quality loss by slicing the ArrayBuffer around the 0xFFE1 segment:

export async function stripExifLossless(file) {
  const buffer = await file.arrayBuffer();
  const view = new DataView(buffer);

  // Verify JPEG header
  if (view.getUint16(0, false) !== 0xffd8) {
    return file; // Not a JPEG, return untouched
  }

  const chunks = [];
  let cursor = 0;
  let offset = 2;

  // Add the initial SOI marker
  chunks.push(buffer.slice(0, 2));
  cursor = 2;

  while (offset < buffer.byteLength - 4) {
    const marker = view.getUint16(offset, false);

    // Hit SOS or EOI - copy the remainder of the compressed image
    if (marker === 0xffda || marker === 0xffd9) {
      break;
    }

    const segmentLength = view.getUint16(offset + 2, false);
    const totalSegmentBytes = 2 + segmentLength;

    // If marker is APP1 (EXIF) or APP2 (FlashPix/ICC profiles if desired), omit it
    if (marker === 0xffe1) {
      // Push everything up to this marker
      if (offset > cursor) {
        chunks.push(buffer.slice(cursor, offset));
      }
      // Skip the entire EXIF block without copying
      cursor = offset + totalSegmentBytes;
    }

    offset += totalSegmentBytes;
  }

  // Append all remaining bytes (raw image data through EOI)
  if (cursor < buffer.byteLength) {
    chunks.push(buffer.slice(cursor));
  }

  // Construct a sanitized Blob with original MIME type
  return new Blob(chunks, { type: file.type || "image/jpeg" });
}
Enter fullscreen mode Exit fullscreen mode

Why This Architecture Matters:

  1. 100% Lossless: Not a single DCT coefficient or quantization table is re-encoded. The resulting photo is mathematically identical in image quality, just lighter by the size of the removed tags.
  2. Instant Performance: Parsing or removing headers takes 2–5 milliseconds even on 40MB RAW/JPEG smartphone exports because the CPU only slices memory buffers.
  3. Absolute Privacy: User files never leave the browser's JavaScript sandbox. There are no tracking scripts, no database logs, and no S3 buckets.

You can inspect full EXIF payloads, check GPS location tags, and test the zero-upload sanitizer live at ScanMetadata.

The complete source code and binary parsing implementation is open-sourced on GitHub.

Top comments (0)