DEV Community

Віталік Бакута
Віталік Бакута

Posted on AI-assisted

Remove GPS and EXIF from a JPEG in the browser, without re-encoding it

Every photo from a phone carries a small hidden file inside it: EXIF metadata. Camera model, lens, timestamps, sometimes the owner's name, and, if location was on, GPS coordinates precise enough to find a house.

Some platforms strip this when you upload. Many paths don't: sending a photo "as a file" in a messenger, email attachments, shared cloud links, forums, marketplaces, your own website. If you're not sure, assume the metadata travels with the file.

In a previous post I showed that drawing an image to a canvas drops metadata as a side effect. That works, but it re-encodes the JPEG and loses a bit of quality every time. Here's the other approach: cut the metadata out of the file byte by byte, and leave the image data untouched.

How a JPEG is laid out

A JPEG is a sequence of segments. Each one starts with a marker: the byte 0xFF followed by a type byte. Most segments then have a 2-byte big-endian length (which includes those 2 bytes).

FF D8                 SOI   start of image
FF E0 len ...         APP0  JFIF header
FF E1 len "Exif" ...  APP1  EXIF (camera, dates, GPS...)
FF E1 len "http://ns.adobe.com/xap/1.0/" ...  APP1  XMP
FF E2 len ...         APP2  ICC colour profile
FF ED len ...         APP13 Photoshop / IPTC
FF DB len ...         DQT   quantization tables
FF C0 len ...         SOF   frame header (size, components)
FF C4 len ...         DHT   Huffman tables
FF DA len ...         SOS   start of scan, then compressed pixels
FF D9                 EOI   end of image
Enter fullscreen mode Exit fullscreen mode

The metadata lives in the APPn segments before the image data. The pixels start after SOS. So stripping metadata means: walk the segments, drop the ones you don't want, copy the rest unchanged.

Step 1: see what's inside

async function listSegments(file) {
  const b = new Uint8Array(await file.arrayBuffer());
  if (b[0] !== 0xff || b[1] !== 0xd8) throw new Error('Not a JPEG');

  const segments = [];
  let i = 2;
  while (i < b.length && b[i] === 0xff) {
    const marker = b[i + 1];
    if (marker === 0xda) break; // SOS: compressed data follows
    const len = (b[i + 2] << 8) | b[i + 3];
    const id = String.fromCharCode(...b.subarray(i + 4, i + 8));
    segments.push({ marker: marker.toString(16), bytes: len + 2, id });
    i += 2 + len;
  }
  return segments;
}
Enter fullscreen mode Exit fullscreen mode

On a typical phone photo you'll see an e1 segment starting with Exif that's several kilobytes, sometimes much more, because it also holds an embedded thumbnail.

Step 2: drop the metadata segments

const DROP = new Set([
  0xe1, // APP1: EXIF and XMP
  0xed, // APP13: Photoshop / IPTC
  0xfe, // COM: comments
]);

async function stripJpegMetadata(file) {
  const b = new Uint8Array(await file.arrayBuffer());
  if (b[0] !== 0xff || b[1] !== 0xd8) throw new Error('Not a JPEG');

  const parts = [b.subarray(0, 2)]; // SOI
  let i = 2;
  while (i < b.length) {
    if (b[i] !== 0xff) throw new Error('Unexpected byte at ' + i);
    const marker = b[i + 1];

    if (marker === 0xda) {
      parts.push(b.subarray(i)); // SOS + image data + EOI, untouched
      break;
    }

    const len = (b[i + 2] << 8) | b[i + 3];
    if (!DROP.has(marker)) parts.push(b.subarray(i, i + 2 + len));
    i += 2 + len;
  }

  return new Blob(parts, { type: 'image/jpeg' });
}
Enter fullscreen mode Exit fullscreen mode

That's it. No canvas, no quality loss, and the output is usually smaller. Everything happens in memory; nothing is uploaded.

Note what we keep: APP0 (JFIF) and APP2, because APP2 usually holds the ICC colour profile. Drop it and wide-gamut photos can look dull. This is a minimal version: a production parser should also handle padding 0xFF bytes between segments and check that lengths don't run past the end of the file.

The catch: orientation

Phones often save the pixels sideways and add an EXIF flag that says "rotate 90° when displaying". Remove EXIF and that flag goes too, so a portrait photo can suddenly show up lying on its side.

Two fixes:

  • Keep a minimal EXIF with only the orientation tag. Libraries like piexifjs can build one.
  • Rotate the pixels for those images: decode with createImageBitmap() (which applies orientation), draw to a canvas and re-encode. You lose the "no re-encoding" benefit, but only for rotated photos.

Read the orientation before you strip, then decide per file.

Verify the result

Don't trust your own code with privacy, test it:

  1. Run listSegments() on the output. There should be no e1 segment.
  2. Open the file in an EXIF viewer and confirm there's no GPS block.
  3. Compare the image visually with the original. Since compressed data is copied byte for byte, it should be identical.

What about HEIC, PNG and others?

Same idea, different containers. PNG stores metadata in chunks (eXIf, tEXt, iTXt), which you can drop the same way. HEIC keeps EXIF inside its box structure and is harder to edit safely; converting to JPEG is often the simpler route.

No-code version

If you just need to clean a few photos, FormatMath has a metadata viewer that shows what a file reveals and a remove metadata from photo tool. Both run entirely in your browser, so the photo never leaves your device.

Have you ever checked what your own photos give away? The GPS block is usually the part that surprises people.

Top comments (0)