Every photo you take carries a hidden backpack of metadata. Camera model, lens settings, GPS coordinates, timestamps — it's all buried in the EXIF data. Most people don't know it's there. Some people really don't want it there.
I built an EXIF editor that runs entirely in your browser. Drop a JPEG, PNG, WebP, or TIFF file in, see every metadata field, edit what you want, delete what you don't, and download a clean copy. No uploads, no servers, no "we'll process it for you." Your image never leaves your device.
You can try it right now on our free online EXIF editor.
Why Keep This in the Browser?
EXIF data often contains sensitive information. GPS coordinates can reveal your home address. Timestamps can expose your daily routine. Camera serial numbers can track your gear. Sending all of that to a third-party server just to strip a few fields is absurd.
Privacy by Design
When everything runs client-side, your image bytes stay on your machine. The tool doesn't even have a backend to leak data to. It's physically impossible for us to see your photos because we never receive them.
Instant Processing
No upload queue, no processing delay. A 10 MB image gets parsed, edited, and re-encoded in milliseconds. The bottleneck is your disk read speed, not network bandwidth.
Works Offline
Load the page once and you can scrub metadata from images even without internet. Useful when you're traveling, on metered connections, or just paranoid about network traffic.
No Account, No Limits
No sign-up forms, no daily quotas, no watermarks. Process as many images as you want. The only limit is your browser's memory.
The Full Pipeline From Drop to Download
Here's what happens under the hood when you drop an image into the editor:
The entire engine is a from-scratch JavaScript implementation inspired by Perl's Image::ExifTool. Let's walk through the interesting parts.
File Type Detection: Reading the Magic
Before we can parse anything, we need to know what we're looking at. File extensions lie. Magic numbers don't.
function extractMetadata(input, options = {}) {
let buffer;
if (input instanceof ArrayBuffer) {
buffer = Buffer.from(input);
} else if (input instanceof Uint8Array) {
buffer = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
}
const sig2 = buffer.toString('ascii', 0, 2);
const sig4 = buffer.toString('ascii', 0, 4);
const sig8 = buffer.length >= 12 ? buffer.toString('ascii', 8, 12) : '';
const magic16 = (buffer[0] << 8) | buffer[1];
const hex4 = buffer.slice(0, 4).toString('hex');
if (magic16 === 0xFFD8) return extractFromJPEG(buffer, options);
if (hex4 === '89504e47') return extractFromPNG(buffer, options);
if (sig4 === 'RIFF' && sig8 === 'WEBP') return extractFromWebP(buffer, options);
if (sig2 === 'II' || sig2 === 'MM') return extractFromTIFF(buffer, options);
throw new Error(`Unsupported file format (signature: ${hex4})`);
}
-
JPEG: starts with
FF D8 -
PNG: starts with
89 50 4E 47 -
WebP:
RIFF....WEBPat offset 0 and 8 -
TIFF/RAW: starts with
II(little-endian) orMM(big-endian)
This covers the vast majority of images people actually use, including RAW files built on TIFF containers.
JPEG Parsing: Hunting for the APP1 Marker
JPEG files are a stream of segments, each starting with a 0xFF marker byte. EXIF data lives in the APP1 segment (0xFFE1), prefixed with the ASCII string "Exif\0\0".
function parseJPEG(buffer) {
const reader = new ByteReader(buffer);
const segments = [];
let pos = 2; // Skip SOI marker
while (pos < buffer.length - 1) {
if (reader.get8u(pos) !== 0xFF) {
pos++;
continue;
}
let marker = 0xFF;
while (pos < buffer.length - 1 && marker === 0xFF) {
pos++;
marker = reader.get8u(pos);
}
pos++;
const markerCode = 0xFF00 | marker;
// Standalone markers (no length field)
if (marker === 0x00 || marker === 0x01 ||
(marker >= 0xD0 && marker <= 0xD9)) {
segments.push({ marker: markerCode, offset: pos - 2, length: 0, data: null });
if (marker === 0xDA) break; // SOS - image data starts, stop scanning
continue;
}
const length = (reader.get8u(pos) << 8) | reader.get8u(pos + 1);
const data = buffer.slice(pos + 2, pos + length);
segments.push({ marker: markerCode, offset: pos - 2, length, data });
pos += length;
}
return { segments, buffer, size: buffer.length };
}
The parser walks through markers until it hits SOS (0xFFDA), which signals the start of the actual compressed image stream. Everything before that is metadata. We extract the APP1 segment, strip the 6-byte "Exif\0\0" header, and hand the remaining TIFF data to the IFD parser.
The Heart of It All: TIFF IFD Parsing
Every format we support — JPEG, PNG, WebP, TIFF itself — stores EXIF data as a TIFF container. Understanding TIFF is the master key.
A TIFF file starts with an 8-byte header:
- Bytes 0–1: Byte order (
II= little-endian,MM= big-endian) - Bytes 2–3: Magic number (
0x002A) - Bytes 4–7: Offset to the first Image File Directory (IFD)
Each IFD is a directory of tag entries. Think of it as a key-value store where keys are 16-bit tag IDs and values can be strings, numbers, arrays, or even pointers to other IFDs.
function parseTIFF(data, options = {}) {
const reader = new ByteReader(data);
const byteOrder = data.toString('ascii', 0, 2);
reader.setByteOrder(byteOrder);
const identifier = reader.get16u(2);
const ifdOffset = reader.get32u(4);
const result = { ExifByteOrder: byteOrder, tags: {}, groups: {} };
const state = { reader, data, byteOrder, visited: new Set(), options };
let nextOffset = processIFD(state, ifdOffset, 'IFD0', result);
if (nextOffset && nextOffset > 0) {
processIFD(state, nextOffset, 'IFD1', result); // thumbnail
}
return result;
}
The processIFD function reads the number of entries, loops through each 12-byte tag record, and dispatches to the appropriate value reader based on the tag's data format (BYTE, ASCII, SHORT, LONG, RATIONAL, etc.).
Handling Nested IFDs
EXIF isn't flat. IFD0 can contain offsets to sub-IFDs:
-
ExifIFD(tag0x8769): camera settings like ISO, aperture, shutter speed -
GPSInfo(tag0x8825): latitude, longitude, altitude -
InteropIFD(tag0xA005): interoperability info -
IFD1: thumbnail image
The parser recursively follows these offsets, tracking visited locations to prevent infinite loops from malformed files.
Value Conversion
Raw TIFF values are often meaningless without context. A GPS latitude isn't a single number — it's three rationals representing degrees, minutes, and seconds. The ValueConverter.js module handles these translations:
// GPS coordinate: [deg/1, min/1, sec/100] → "40.7128° N"
function printGPSCoord(value, ref) {
const deg = value[0][0] / value[0][1];
const min = value[1][0] / value[1][1];
const sec = value[2][0] / value[2][1];
const decimal = deg + min / 60 + sec / 3600;
return `${decimal.toFixed(4)}° ${ref}`;
}
The Editor: Actually Modifying Metadata
Parsing is half the battle. The other half is rewriting the file with your changes applied. The ExifEditor class provides a simple API modeled after Perl's Image::ExifTool:
class ExifEditor {
constructor() {
this.newValues = {};
this.deletedTags = new Set();
this.byteOrder = 'II';
}
setNewValue(tagName, value) {
this.newValues[tagName] = value;
this.deletedTags.delete(tagName);
}
deleteTag(tagName) {
this.deletedTags.add(tagName);
delete this.newValues[tagName];
}
}
When you hit "Process EXIF," the editor:
- Reads the current metadata from the image
- Gathers all tags organized by IFD
- Removes tags in the deletion set
- Overwrites tags with new values
- Rebuilds the TIFF EXIF block from scratch
- Injects it back into the original file format
Rebuilding the TIFF Block
The buildTIFF function in ExifWriter.js is essentially the inverse of the parser. It takes tags grouped by IFD, resolves tag names to numeric IDs, determines the correct binary format for each value, and lays out the entire directory structure:
function buildTIFF(tagsByIFD, tagTable, byteOrder = 'II', gpsTagTable = null) {
const isLE = byteOrder === 'II';
// Resolve tag names to IDs and formats
const ifdData = {};
for (const [ifdName, tags] of Object.entries(tagsByIFD)) {
const entries = [];
const activeTable = (ifdName === 'GPS') ? gpsTagTable : tagTable;
for (const [tagName, value] of Object.entries(tags)) {
const tagID = findTagID(activeTable, tagName);
const format = resolveFormat(value, tagInfo);
entries.push({ tagID, name: tagName, value, format });
}
entries.sort((a, b) => a.tagID - b.tagID);
ifdData[ifdName] = entries;
}
// ...layout calculation and binary serialization
}
Tags are sorted by ID (TIFF spec requirement). Values larger than 4 bytes get stored in a data area after the directory entries, with the directory entry containing an offset pointer. Smaller values fit directly into the 4-byte "value/offset" field of the entry.
Format-Specific Writeback
Different image formats embed EXIF data differently, so the editor handles each one separately.
JPEG: Replace the APP1 Segment
_writeJPEG(buffer, currentMeta) {
const exifBlock = this._buildExifBlock(currentMeta);
// Find existing APP1 segment
let app1Offset = -1, app1Length = 0, pos = 2;
while (pos < buffer.length - 3) {
if (buffer[pos] === 0xFF && buffer[pos + 1] === 0xE1) {
app1Offset = pos;
app1Length = 2 + ((buffer[pos + 2] << 8) | buffer[pos + 3]);
break;
}
// ...skip other markers
}
const app1Data = Buffer.concat([Buffer.from('Exif\0\0', 'ascii'), exifBlock]);
const newApp1Segment = Buffer.concat([
Buffer.from([0xFF, 0xE1]),
writeUInt16BE(app1Data.length + 2),
app1Data
]);
if (app1Offset < 0) {
// No existing EXIF: insert after SOI
return Buffer.concat([buffer.slice(0, 2), newApp1Segment, buffer.slice(2)]);
}
// Replace existing APP1
return Buffer.concat([
buffer.slice(0, app1Offset),
newApp1Segment,
buffer.slice(app1Offset + app1Length)
]);
}
If you're stripping all EXIF data, the APP1 segment is simply removed. If you're adding EXIF to a clean JPEG, it gets inserted immediately after the SOI marker — the standard location.
PNG: eXIf Chunk Management
PNG stores EXIF in an eXIf chunk (or the older non-standard zxIf). The editor scans the existing chunks, replaces the EXIF chunk if present, or inserts a new one before the first IDAT chunk:
_writePNG(buffer, currentMeta) {
const exifBlock = this._buildExifBlock(currentMeta);
const pngInfo = parsePNG(buffer);
let exifChunkIndex = -1;
for (let i = 0; i < pngInfo.chunks.length; i++) {
if (pngInfo.chunks[i].type === 'eXIf' || pngInfo.chunks[i].type === 'zxIf') {
exifChunkIndex = i;
break;
}
}
const newExifChunk = makePngChunk('eXIf', exifBlock);
const parts = [Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])];
if (exifChunkIndex >= 0) {
// Replace existing chunk
for (let i = 0; i < pngInfo.chunks.length; i++) {
if (i === exifChunkIndex) parts.push(newExifChunk);
else parts.push(makePngChunk(pngInfo.chunks[i].type, pngInfo.chunks[i].data));
}
} else {
// Insert before first IDAT
let idatIndex = pngInfo.chunks.findIndex(c => c.type === 'IDAT');
if (idatIndex < 0) idatIndex = pngInfo.chunks.length;
for (let i = 0; i < pngInfo.chunks.length; i++) {
if (i === idatIndex) parts.push(newExifChunk);
parts.push(makePngChunk(pngInfo.chunks[i].type, pngInfo.chunks[i].data));
}
}
return Buffer.concat(parts);
}
Note that the PNG signature and CRC calculations are preserved. We're only touching the metadata chunks, never recompressing the actual image data.
WebP: RIFF Chunk Surgery
WebP uses RIFF chunks, and EXIF lives in an EXIF chunk. The approach is similar to PNG — rebuild the RIFF structure with the new or removed EXIF chunk:
_writeWebP(buffer, currentMeta) {
const exifBlock = this._buildExifBlock(currentMeta);
const webpInfo = parseWebP(buffer);
let exifChunkIndex = -1;
for (let i = 0; i < webpInfo.chunks.length; i++) {
if (webpInfo.chunks[i].type === 'EXIF') {
exifChunkIndex = i;
break;
}
}
const chunkBufs = [];
if (exifChunkIndex >= 0) {
for (let i = 0; i < webpInfo.chunks.length; i++) {
if (i === exifChunkIndex) chunkBufs.push(makeRiffChunk('EXIF', exifBlock));
else chunkBufs.push(makeRiffChunk(webpInfo.chunks[i].type, webpInfo.chunks[i].data));
}
} else {
for (const chunk of webpInfo.chunks) {
chunkBufs.push(makeRiffChunk(chunk.type, chunk.data));
}
chunkBufs.push(makeRiffChunk('EXIF', exifBlock));
}
const allData = Buffer.concat(chunkBufs);
const fileSize = 4 + allData.length;
const riffHeader = Buffer.concat([
Buffer.from('RIFF'), writeUInt32LE(fileSize), Buffer.from('WEBP')
]);
return Buffer.concat([riffHeader, allData]);
}
TIFF: Direct IFD Rewrite
For TIFF files (and TIFF-based RAW files), the EXIF data is the file structure. The editor rebuilds the entire IFD structure and returns it as the new file buffer.
The React UI: Making Bytes Human-Readable
The frontend is a React client component that bridges raw binary metadata and human-friendly editing. Key design decisions:
Categorized Field Display
With over 100 possible EXIF tags, a flat list is overwhelming. Fields are grouped into categories:
const categories = [
{ key: "camera", label: "Camera", emoji: "📷" },
{ key: "datetime", label: "Date/Time", emoji: "🕐" },
{ key: "exposure", label: "Exposure", emoji: "☀️" },
{ key: "lens", label: "Lens", emoji: "🔭" },
{ key: "color", label: "Color", emoji: "🎨" },
{ key: "author", label: "Author", emoji: "👤" },
{ key: "location", label: "Location", emoji: "📍" },
];
Each field shows a checkbox (to keep or remove), a label, the current value, and an edit button. Modified fields get a green badge. New fields get a blue badge.
Smart Value Formatting
GPS coordinates get degree symbols and hemisphere labels. Exposure times like "1/250" stay as fractions. ISO values stay plain numbers. The formatExifValue function handles the messiness:
function formatExifValue(key, value) {
if (typeof value === 'number') {
if (key.toLowerCase().includes('latitude') || key.toLowerCase().includes('longitude')) {
return value.toFixed(6) + '°';
}
if (key.toLowerCase().includes('altitude')) {
return value.toFixed(1) + ' m';
}
}
if (value instanceof Date) return value.toLocaleString();
if (Array.isArray(value)) return value.join(', ');
return String(value);
}
The Edit Workflow
The Browser Buffer Problem
One tricky detail: the core parser was originally written for Node.js, which has a native Buffer class. Browsers don't. The browser entry point fixes this by polyfilling Buffer into globalThis before loading any internal modules:
import { Buffer } from "buffer";
if (typeof globalThis !== "undefined" && !globalThis.Buffer) {
globalThis.Buffer = Buffer;
}
import { extractMetadata } from "./src/ExifTool.js";
import { ExifEditor } from "./src/ExifEditor.js";
This lets us reuse the same parsing and writing code across Node.js CLI tools and the browser UI without forking the logic.
Why Build Instead of Using an Existing Library?
There are existing JavaScript EXIF libraries. Most of them only read metadata. The ones that write often only support JPEG, or they require WASM binaries, or they don't handle PNG/WebP at all. Building our own gave us:
- Full read/write support for JPEG, PNG, WebP, and TIFF
- Pure JavaScript — no WASM, no native modules, no service workers
- Complete control over which tags get preserved, modified, or stripped
- Small bundle size — we only ship the tag tables we actually use
Try It Yourself
Got a photo with questionable metadata? Want to strip GPS before posting to social media? Need to batch-edit copyright info on a folder of images?
Head over to our free online EXIF editor. Upload your image, check the fields you want to keep, edit or delete the rest, and download a clean copy. Everything happens in your browser — your photos never touch our servers.
This article was originally published by DEV Community and written by monkeymore studio.
Read original article on DEV Community
