How JPEG Hides Metadata: Building an EXIF Parser from Scratch#

Published: 2026-05-02 04:01

When you snap a photo, your gallery app shows not just the image but also the timestamp, aperture, and GPS coordinates. Where does this data live? Let’s crack open a JPEG file and build an EXIF parser.

JPEG Segments#

A JPEG file isn’t one monolithic block — it’s a series of Segments. Each starts with 0xFF followed by a marker byte:

0xFF 0xD8 → SOI (Start of Image)
0xFF 0xE1 → APP1 (EXIF data segment)
...
0xFF 0xD9 → EOI (End of Image)

EXIF data lives inside the 0xFF 0xE1 APP1 marker. So the first step is scanning the binary data for 0xFFE1:

const buffer = await file.arrayBuffer()
const data = new Uint8Array(buffer)

let exifOffset = -1
for (let i = 0; i < data.length - 1; i++) {
  if (data[i] === 0xFF && data[i + 1] === 0xE1) {
    exifOffset = i
    break
  }
}

if (exifOffset === -1) {
  // This image has no EXIF data
}

Once we find the APP1 marker, the real work begins.

Endianness Detection#

EXIF metadata uses the TIFF format internally. The first two bytes tell you the byte order — 0x4949 (“II”) means Little-Endian, 0x4D4D (“MM”) means Big-Endian:

const tiffOffset = exifOffset + 4
const byteOrderLE = data[tiffOffset] === 0x49

// Validate the TIFF magic number (must be 0x002A)
const byteOrderVal = readUint16(data, tiffOffset + 2, byteOrderLE)
if (byteOrderVal !== 0x002A) {
  // Not valid TIFF
}

The helper reads via DataView.getUint16 with the endian flag:

function readUint16(data: Uint8Array, offset: number, littleEndian: boolean): number {
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
  return view.getUint16(offset, littleEndian)
}

Getting the endianness wrong corrupts every value you read. This is the most common trap when writing an EXIF parser.

IFD Traversal: The Core Data Structure#

The heart of EXIF is the IFD (Image File Directory) — a table of entries, each exactly 12 bytes:

Offset Size Meaning
0 2 Tag ID (which property)
2 2 Data type (ASCII, SHORT, LONG, RATIONAL, etc.)
4 4 Count of values
8 4 Value data or offset pointer

The parser iterates through these entries:

const IFD_ENTRY_SIZE = 12

function parseIFD(data: Uint8Array, baseOffset: number, ifdOffset: number, littleEndian: boolean) {
  const tags = []
  const entryStart = baseOffset + ifdOffset
  const entryCount = readUint16(data, entryStart, littleEndian)

  for (let i = 0; i < entryCount; i++) {
    const entryOffset = entryStart + 2 + i * IFD_ENTRY_SIZE
    const tagId = readUint16(data, entryOffset, littleEndian)
    const type = readUint16(data, entryOffset + 2, littleEndian)
    const count = readUint32(data, entryOffset + 4, littleEndian)
    // ...
  }
}

When the value exceeds 4 bytes (like a RATIONAL which is 8 bytes), the last 4 bytes of the entry store an offset pointer instead of the value itself.

RATIONAL types (type=5) are the trickiest — they’re two UINT32s (numerator + denominator):

function readRational(data: Uint8Array, offset: number, littleEndian: boolean): number {
  const num = readUint32(data, offset, littleEndian)
  const den = readUint32(data, offset + 4, littleEndian)
  return den !== 0 ? num / den : 0
}

Sub-IFDs: Exif and GPS#

Two special Tag IDs point to sub-IFDs:

  • 0x8769 → Exif Sub-IFD (exposure, ISO, aperture)
  • 0x8825 → GPS Sub-IFD (latitude, longitude, altitude)

When the parser hits these, it recursively calls parseIFD using the offset stored in the value field:

if (tagId === 0x8769) {
  const subIFDOffset = readUint32(data, valueOffset, littleEndian)
  const subTags = parseIFD(data, baseOffset, subIFDOffset, littleEndian)
  tags.push(...subTags)
  continue
}

if (tagId === 0x8825) {
  const gpsIFDOffset = readUint32(data, valueOffset, littleEndian)
  const gpsTags = parseGPSIFD(data, baseOffset, gpsIFDOffset, littleEndian)
  tags.push(...gpsTags)
  continue
}

GPS Coordinates: DMS to Decimal#

Cameras store GPS as Degrees/Minutes/Seconds (DMS) — three RATIONAL values. Conversion to decimal:

function readRationalAsDegrees(data: Uint8Array, offset: number, littleEndian: boolean): number {
  const deg = readRational(data, offset, littleEndian)
  const min = readRational(data, offset + 8, littleEndian)
  const sec = readRational(data, offset + 16, littleEndian)
  return deg + min / 60 + sec / 3600
}

Then apply the sign from the reference fields:

latitude = latRef === 'S' ? -num : num
longitude = lonRef === 'W' ? -num : num

Human-Friendly Display#

Raw values aren’t useful to end users. Format them:

if (tagName === 'FNumber') {
  displayValue = `f/${num.toFixed(1)}`  // f/2.8
} else if (tagName === 'ExposureTime') {
  displayValue = num < 1 ? `1/${Math.round(1 / num)} sec` : `${num} sec`
} else if (tagName === 'ISOSpeedRatings') {
  displayValue = `ISO ${value}`
} else if (tagName === 'Flash') {
  displayValue = v & 0x1 ? 'Fired' : 'Did not fire'
}

Zero-Dependency Browser Solution#

The entire EXIF parser uses only Uint8Array and DataView — roughly 300 lines of TypeScript. No third-party libraries needed.

Try it online: JsonKit EXIF Viewer. Drag a JPEG, see camera info, GPS on a map, and export as text.


Related tools: IP Geo Location | Port Checker | Text Deduplicator