Rescuing exifr's broken GPS parsing on iPhone 17 HEIC with a browser-only fallback
Contents
A common pattern for small personal tools is a browser-only map app where you drag and drop photos taken on an iPhone and watch them get pinned onto a map automatically. Then iPhone 17 arrives, and suddenly every pin from HEIC photos collapses to (0, 0) in the middle of the Atlantic, or GPS is missing entirely.
Jacob Mei’s DEV.to post lays out the cause and a “fix without fully migrating” approach. I’ll capture the key points and add some practical notes for web-side implementation.
What was actually happening
The symptom is simple: feed an iPhone 17 HEIC into a browser, and exifr (a lightweight, widely used EXIF parser) returns no metadata.
The file itself isn’t corrupt. Open it in Finder on a Mac and the coordinates and timestamps show up fine. Yet browser-side exifr insists it can’t read it.
The root cause lives in the ftyp box at the head of the HEIC.
ftyp box and brand identifiers
HEIC sits on top of the ISO Base Media File Format (BMFF, a cousin of MP4).
At the very start of the file is a ftyp box declaring which specs the file conforms to, with a major brand and a list of compatible brands. It’s where the file says “I’m HEIC, but I’m also compatible with HEIF and MIF1.”
On iPhone 16 and earlier, this ftyp box came in at around 44 bytes.
iPhone 17 adds two new identifiers, MiHA and heix, and the box swells to 52 bytes.
| Identifier | Rough role |
|---|---|
heic | HEIC itself |
mif1 | HEIF Image File Format |
MiHA | Apple’s proprietary HEIC extension variant (added with iPhone 17) |
heix | HEIC eXtended (broader profile) |
MiHA and heix themselves are Apple-flavored extensions, and parsers that don’t know about them can ignore them without issue. The actual problem is that the total size of the ftyp box grew.
exifr’s “bail if over 50 bytes” guard
The HEIC-reading path inside exifr has roughly this early return:
if (ftypLength > 50) return false;
It’s a defensive shortcut along the lines of “if the ftyp is unusually large, this probably isn’t HEIC (or probably isn’t safe to touch).” That kind of hard-coded threshold is how exifr keeps its bundle around 18KB: scope things down and don’t touch anything unexpected.
iPhone 17 weighs in at 52 bytes. Not a 1-byte overrun, but a 2-byte one that lands squarely inside the guard. Parsing aborts at that point, and the EXIF/GPS data that is sitting intact further inside the file becomes completely unreachable.
What’s insidious here is that this isn’t “HEIC not recognized.” It’s “reject before looking inside,” so both the error and the GPS come back as null. From the frontend’s point of view, there’s no way to tell the photo “has no GPS” apart from “the parser gave up."
"Fall back” instead of “migrate”
Mei’s call was to keep exifr and only reach for a different parser when HEIC fails.
The backup is ExifReader. It’s more forgiving about HEIC brand identifiers than exifr and happily extracts GPS even from a 52-byte ftyp iPhone 17 HEIC.
The catch is that ExifReader covers more ground and weighs in at around 34KB, versus exifr’s 18KB. Shipping both to every user for every photo is wasteful.
So the fix is a dynamic import: try exifr first, and only chunk-load ExifReader when exifr comes back empty.
const { gps, meta } = await tryExifr(file);
if (gps !== null || meta !== null) return { gps, meta };
// Only reach for the heavier parser when exifr returned nothing
const [{ default: ExifReader }, buf] = await Promise.all([
import('exifreader'),
file.arrayBuffer(),
]);
Two things matter here.
First, don’t replace exifr outright.
Most photos — JPEGs, anything from Android devices — still go through the lightweight parser as before, and the initial bundle stays small.
Second, parallelize the dynamic import and arrayBuffer() via Promise.all.
The chunk fetch for ExifReader (a network round trip) and the local ArrayBuffer conversion are independent costs; awaiting them serially leaves perceptible latency on the table. It’s a small thing, but it’s visible in the time between drop and pin.
flowchart TD
A[HEIC/JPEG file] --> B[Try GPS extract with exifr]
B -->|GPS found| Z[Place pin on map]
B -->|null/empty| C[dynamic import: ExifReader]
C --> D[Re-parse with ExifReader]
D -->|GPS found| Z
D -->|still null| Y[Treat as "no location"]
“Lightweight canonical parser → heavier, more forgiving parser → give up” layers cleanly without breaking exifr’s assumptions, which makes it a solid bridge while waiting for an upstream fix.
False positives that are easy to hit in browser-only apps
When you’re deciding whether GPS was successfully parsed, the browser-only setting has some specific traps. The four hardening items in the article are less “HEIC-specific” and more “baseline hygiene for apps that handle photos in the client only.”
1. Filter out Null Island
A parser that failed to read GPS will sometimes silently hand back lat=0, lng=0.
That’s the famous “Null Island” out in the Atlantic — nobody actually takes photos there (it’s off the coast of Ghana).
Treating lat === 0 && lng === 0 not as a shot location but as a parse failure by itself kills the common bug of a mountain of pins collecting at the map origin.
A lot of people report that this single rule is the one that paid off the most.
2. Anchor the datetime regex
When extracting DateTimeOriginal from EXIF, it’s easy to reach for something like /\d{4}:\d{2}:\d{2}/, but that becomes “anywhere in the string containing four digits + two digits + two digits matches.”
If random binary noise happens to line up that pattern, junk gets picked up as a date.
// Bad: partial match
const re = /\d{4}:\d{2}:\d{2}/;
// Good: anchored
const re = /^\d{4}:\d{2}:\d{2}$/;
This matters less if you’re slicing directly from a known EXIF field, but if the code is scanning stringified bytes for dates, the anchoring really does work.
3. Cap the file size
const MAX_PHOTO_BYTES = 10 * 1024 * 1024; // 10MB
HEIC can hold multiple images, thumbnails, and auxiliary metadata, which means a malicious file can in principle contain “an image with an absurdly large metadata block” designed to exhaust the parser’s memory. The tool runs entirely in the browser, so OOM translates directly into a tab crash.
10MB is about double a typical iPhone photo (3–5MB for most), which leaves comfortable headroom for real user photos while cutting off extreme outliers at the door.
4. Pre-inspect the BMFF iloc box
This was the most interesting part of the article.
Inside a HEIC (BMFF container) there’s an iloc (Item Location) box describing “item X starts at offset Y and is Z bytes long.”
Attacks against this box are documented, including:
| Pattern | What goes wrong |
|---|---|
| An entry with offset = 0, length = 0 | Parser interprets it as “the whole file,” entering an infinite loop or reading everything |
| Item count inflated beyond the real value | Parser chases entries that don’t exist, burning memory and time |
If you peek at iloc before handing anything to an EXIF parser and check:
- Whether offset / length have suspicious zero patterns
- Whether item count is unrealistically large for the file size
you can block “dies depending on parser bug” one step earlier. Browser-only apps especially don’t have a server-side WAF or a scanning gate in front, so this kind of input validation has to live in the frontend.
Why do this in the browser at all
The underlying premise of the article is that TrailPaint is a tool for dropping GPX and photos into the browser and exporting a map image — nothing is uploaded to a server.
Being serverless is more a stance than a feature set:
- Location data for photos, which for some users ties directly to home, work, or daily patterns, never leaves the device
- No infrastructure makes long-term solo maintenance tractable
- Users don’t need accounts; they just open a URL
The flip side is that you lose the server’s ability to “filter weird images, normalize them, keep parser versions current.” A single hard-coded threshold in a library or a single new iPhone generation can break the whole thing, and for an attacker, “the JS parser in the frontend” is the only line of defense.
Breakage from normal evolution, like iPhone 17’s ftyp extension, needs to be absorbed by fallback design and client-side input validation — that’s quickly becoming the default posture for serverless client apps.
What to check in production
If you’ve got a browser-facing product handling iPhone photos, at minimum work through this list:
- Is the EXIF / GPS parser
exifr? If so, check the version and the HEIC path’s early-return behavior - Test with iPhone 17 HEICs (samples with
MiHA/heixinftyp) on a real device — does GPS come through? - If not, either wait on an
exifrupdate or wire in a dynamic-import fallback toExifReader - While you’re there, audit Null Island filtering, anchored datetime regex, file size cap, and
ilocpre-inspection
iPhone 18 might perfectly well add yet another brand (historically, it seems likely), so “don’t trust a hard-coded-threshold parser as-is” is the quiet but important lesson here.
exifr itself is still a small, well-behaved library — it’s not really at fault. The practical takeaway is the frontend’s stance toward new format extensions: light on the happy path, heavy on fallback when things break. Build that in and even a personal browser-only tool can stick around longer.
What does “item” in iloc actually refer to
A BMFF/HEIC file isn’t a single “image byte blob” like a JPEG — it’s a container with several items inside. The Exif and GPS data you want out of a HEIC is just one of those items. The canonical items roughly look like:
| Item kind | What’s inside |
|---|---|
| Primary image | The HEVC-compressed image that’s actually displayed |
| Thumbnail | A smaller image for lists (stored as a separate item) |
| Exif metadata | Shot time, GPS, exposure info and so on |
| depth map / portrait matte | Depth-of-field data from portrait mode |
| gain map | Brightness correction map for HDR playback |
| Live Photo reference | An entry pointing to the sibling MOV, etc. |
The iloc box holds, for each of these items, “at offset X in the file, read Y bytes.”
From the EXIF parser’s point of view, it asks iloc where the Exif item is, jumps there, and parses just that byte range.
So tampering with iloc coaxes the EXIF parser into “read an unrelated region as Exif,” “read a huge range entirely,” or “chase an item that doesn’t exist.”
This is the class of attack surface particular to container formats with multiple parallel items.
What about a photo genuinely taken at (0, 0) in the ocean off Ghana?
Null Island filtering throws out lat === 0 && lng === 0, which raises the question of what happens to a photo actually taken at the intersection of the equator and the prime meridian (off the coast of Ghana).
The short answer: taking a shot at exactly (0, 0) basically never happens.
GPS coordinates are recorded with several decimal places of precision, so lat = 0.0, lng = 0.0 matching exactly is essentially impossible in practice.
Even a photo taken from a boat off Ghana will record something like lat = 0.000834, lng = -0.012103 — some awkward fractional value.
Practically, either of these thresholds is enough:
- Reject only strictly
lat === 0 && lng === 0as “parser failure” - Reject within a tiny margin like
Math.abs(lat) < 1e-7 && Math.abs(lng) < 1e-7
If you widen that to something like Math.abs(lat) < 0.001, you start sweeping up real equatorial photos (northern Brazil, Indonesia, Kenya, Gabon, the Congo Basin) — the equator has plenty of people living along it, so being sloppy here causes real damage.
If you want extra certainty, combine this with other GPS fields (GPSVersionID, GPSTimeStamp, GPSAltitude, etc.). A parse failure tends to wipe out all of them together, whereas a real (0,0)-ish shot usually has the other fields intact — so “lat/lng look like (0,0) but the rest is healthy” is real, and “everything null” is a parse failure.
Why not fork exifr and raise the threshold?
Forking exifr to bump the ftyp > 50 guard to 64 or 128 would have worked, and in other contexts I’d reach for exactly that kind of patch.
Choosing dynamic import fallback instead was a call with several reasons behind it.
First is follow-up cost.
exifr is actively maintained, and the moment you fork it, you’ve taken on the job of continuously merging upstream bug fixes, new device support, and security patches.
Taking on that maintenance burden for a one-line patch in a personal tool is a bad trade.
Second is the assumption that the cause is a single threshold.
There’s no guarantee Apple stops at MiHA — new iPhone generations can easily introduce more brand identifiers or box structure changes.
Just bumping the threshold might not be what’s needed next time; the next fix might be loosening a different guard, or adding a different brand. Rather than growing a fork bit by bit, routing the failure cases into a permissively-designed alternative implementation (ExifReader) is easier to reason about.
Third is reversibility.
With a dynamic import gate, when exifr upstream fixes the iPhone 17 issue, you just delete the fallback path and you’re back to the clean setup.
Forks tend to become “no longer needed but impossible to remove because references are everywhere,” so keeping the bridge in a shape that’s easy to tear down preserves the option of returning to upstream.
Fourth is not wanting to tamper with the lightweight parser’s philosophy.
The ftyp > 50 limit in exifr rests on a defensive principle of “don’t touch unexpected input,” and forking to loosen it means you’re personally absorbing the risk when a genuinely malformed HEIC shows up.
Swapping parsers via dynamic import keeps that decision inside ExifReader’s boundary, leaving exifr’s assumptions untouched.
Loosening a hard-coded threshold in a fork is “fast, but locks you in.” Dynamic import fallback is “a bit more setup up front, but the exit is always open.” For long-running browser-only apps, the latter fits better — that’s the trade being made.