5 Browser File API Gotchas That Will Waste Your Weekend
I once shipped a "photo gallery uploader" that worked flawlessly in demo. Select 30 photos, see thumbnails, hit upload. Beautiful. Then QA threw 200 iPhone photos at it and the tab crashed. No error. No warning. Just dead.
The Chrome task manager showed the tab using 3.8GB of RAM. For thumbnails.
That was the first of many weekends I lost to the Browser File API — an API that's deceptively simple on the surface and full of landmines underneath. The MDN docs tell you how to use FileReader and Blob. They don't tell you where it'll bite you. That's what this post is for.
Gotcha #1: FileReader Creates Memory Leaks That Don't Show Up Until Production
Here's the code pattern I see in roughly 80% of file upload tutorials online:
function previewImages(fileList) {
for (const file of fileList) {
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement('img');
img.src = e.target.result; // This is a base64 data URL. The ENTIRE file in RAM as a string.
document.querySelector('.preview-grid').appendChild(img);
};
reader.readAsDataURL(file);
}
}
Looks fine, right? It works. For 5 files.
The problem: readAsDataURL() converts the file to a base64 string, which is ~33% larger than the original binary. A 5MB photo becomes a ~6.7MB string. Thirty photos? That's 200MB of strings sitting in memory — on top of the original File objects the browser is already holding.
But it gets worse. Those base64 strings are stored as the src attribute of <img> elements. Even if you remove the images from the DOM, the strings persist until garbage collection runs (whenever the browser feels like it). And some browsers — I'm looking at you, older Safari — are very lazy about collecting them.
The fix: Use URL.createObjectURL() and actually revoke it
function previewImages(fileList) {
for (const file of fileList) {
const objectUrl = URL.createObjectURL(file);
const img = document.createElement('img');
img.onload = () => {
// CRITICAL: revoke after the image has loaded
// Skip this and you've got a memory leak that only shows up with 50+ files
URL.revokeObjectURL(objectUrl);
};
img.src = objectUrl;
document.querySelector('.preview-grid').appendChild(img);
}
}
createObjectURL gives you a blob: URL that points to the file data already in memory — no base64 copy. But you must call revokeObjectURL when you're done, or the browser holds a reference to that Blob forever (well, until page unload).
The img.onload callback is the right place. Not setTimeout. Not "later." In the load handler.
Test it yourself: Grab a sample 5MB JPG from TrueFileSize, write a loop that creates 50 object URLs without revoking, and watch the memory tab in DevTools. Then add the revoke. Night and day.
Browser behavior differences
| Browser | readAsDataURL leak severity | createObjectURL without revoke | |---------|---------------------------|-------------------------------| | Chrome 120+ | GC'd eventually, but slowly | Held until page unload | | Firefox | Slightly better GC | Same — held until unload | | Safari 17 | Worst offender by far | Same | | iOS Safari | Tab killed by OS at ~1.5GB | Same (kills tab faster) |
Gotcha #2: Blob.slice() Has an Off-by-One on Safari (Yes, Really)
If you've built chunked uploads, you've used Blob.slice(). It's the foundation of every chunked upload implementation. And it has a bug on Safari that I spent two days tracking down.
The spec says Blob.slice(start, end) returns bytes from start up to but not including end. Same as Array.slice(). And it works that way on Chrome and Firefox. Safari... mostly agrees, except when slicing near the end of a file.
// Chunked upload — the standard pattern
async function uploadInChunks(file) {
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
// On Safari, the LAST chunk can sometimes be 1 byte short
// when file.size isn't a clean multiple of CHUNK_SIZE
console.log(`Chunk ${i}: expected ${end - start} bytes, got ${chunk.size}`);
await uploadChunk(chunk, i, totalChunks);
}
}
On Safari (tested up to 17.4), the final chunk's .size occasionally reports one byte less than end - start for certain file sizes. The server receives a file that's 1 byte short. Checksums fail. Corruption ensues. Users file a bug that says "uploads work on Chrome but not Safari" and you question your career choices.
The fix: Verify chunk sizes and use explicit end bounds
async function uploadInChunksSafe(file) {
const CHUNK_SIZE = 5 * 1024 * 1024;
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
const end = Math.min(offset + CHUNK_SIZE, file.size);
const chunk = file.slice(offset, end);
// Paranoia check — if this fires, you've hit the Safari bug
if (chunk.size !== (end - offset)) {
console.warn(
`Blob.slice size mismatch at offset ${offset}: ` +
`expected ${end - offset}, got ${chunk.size}. Re-slicing.`
);
// Re-read from the File object (not from the chunk)
const fixedChunk = file.slice(offset, offset + chunk.size + 1);
await uploadChunk(fixedChunk, chunkIndex);
} else {
await uploadChunk(chunk, chunkIndex);
}
offset = end;
chunkIndex++;
}
}
Is this paranoid? Yes. Has it saved me in production? Also yes.
I should be honest — I've only been able to reproduce this consistently on Safari 16.x and sporadically on 17.x. It might be fixed in a future release. But defensive code costs you nothing, and the alternative is a corruption bug you can't reproduce on your Macbook because of course you develop on Chrome.
Test your chunked upload with files at awkward sizes — not just round megabytes. Try a 500KB JPG (which won't split evenly into 5MB chunks... because it's smaller than one chunk — good edge case) or a 10MB PDF that splits into two uneven pieces.
Gotcha #3: File.lastModified Is Unreliable on Mobile (And Sometimes Desktop)
The File object has a .lastModified property. Handy for cache busting, deduplication, or just displaying "this photo was taken on..." in your UI.
Don't trust it.
document.querySelector('input[type="file"]').addEventListener('change', (e) => {
const file = e.target.files[0];
console.log(file.name); // "IMG_4382.HEIC" — looks right
console.log(file.lastModified); // 1609459200000 — Jan 1, 2021 00:00:00 UTC
// ...really? Every photo taken at midnight on New Year's?
});
On iOS Safari, lastModified often returns the timestamp of when the file was added to the browser's temporary upload cache — not when the photo was actually taken or last modified on disk. On some Android browsers, it's always epoch (0) or the current timestamp. Chrome desktop is usually accurate, but even there, files downloaded from the web get the download timestamp, not the original creation date.
What actually works: Read EXIF data
If you need the real date for images, read the EXIF metadata. The File API won't give it to you reliably.
// Using exifr — lightweight, works in browser
import exifr from 'exifr';
async function getRealDate(file) {
try {
const exif = await exifr.parse(file, ['DateTimeOriginal', 'CreateDate']);
// EXIF dates are strings like "2024:03:15 14:23:01"
// (yes, colons in the date part — thanks, EXIF standard)
if (exif?.DateTimeOriginal) {
return new Date(exif.DateTimeOriginal);
}
} catch {
// Not a JPEG/TIFF, or no EXIF — fall through
}
// Fallback to lastModified, but now you know it might be wrong
return new Date(file.lastModified);
}
For non-image files? You're basically out of luck in the browser. file.lastModified is all you've got, and it's a suggestion, not a fact. If accurate timestamps matter for your feature, do the validation server-side where you have real filesystem access.
I haven't tested every Android browser out there — there are too many. But the pattern holds: lastModified is "best effort" at best, and "straight-up fiction" at worst.
Gotcha #4: <input type="file"> Won't Fire onChange If You Pick the Same File
This one's more of an HTML quirk than a File API bug, but it bites everyone building upload UIs.
Scenario: User selects report.pdf. Your onChange handler runs, does validation, shows a preview. User realizes it's the wrong version. They click the file picker again and select report.pdf again (the same filename, but maybe they saved changes to it). Your onChange handler... doesn't fire. Nothing happens. The user is confused.
The <input type="file"> element only fires change when the value changes. Same filename = same value = no event.
<!-- This won't re-trigger for the same file -->
<input type="file" id="upload" />
<script>
document.getElementById('upload').addEventListener('change', (e) => {
// This NEVER fires if the user picks the same file twice
console.log('File selected:', e.target.files[0].name);
});
</script>
The fix: Reset the input value after reading
const fileInput = document.getElementById('upload');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
handleUpload(file);
// Reset so the same file triggers onChange next time
// IMPORTANT: do this AFTER you've grabbed the File reference
e.target.value = '';
});
Setting value = '' clears the input's internal state. Next time the user picks any file — even the same one — it's a "change" and the event fires.
One catch with React: if you're using controlled components or refs, make sure you reset in the right lifecycle phase. The common React pattern:
function FileUploader() {
const inputRef = useRef(null);
const handleChange = (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) return;
processFile(selectedFile);
// Reset after processing — same-file re-select now works
inputRef.current.value = '';
};
return <input ref={inputRef} type="file" onChange={handleChange} />;
}
This works the same across all browsers. It's one of the few things they all agree on.
Gotcha #5: iOS Safari Has a Hard Size Limit for File API Operations
This is the one that'll make you question why you're building for mobile at all.
iOS Safari has memory pressure limits that kill your tab if the File API operations push the WebKit process past ~4GB of virtual memory (the exact number varies by device and iOS version, but 4GB is a safe upper bound for modern iPhones). For older devices with 3GB of physical RAM — iPhone SE 2nd gen, for instance — you'll hit this much sooner.
What does "kill your tab" mean in practice? No error event. No exception. The browser just... reloads the page. Your upload state, preview thumbnails, form data — gone.
// This will kill the tab on a 3GB iPhone with a large enough file
async function readEntireFile(file) {
const buffer = await file.arrayBuffer(); // Loads ENTIRE file into WASM-accessible memory
// On a 2GB video file, you've just doubled memory usage
// (the File object + the ArrayBuffer copy)
return processBuffer(buffer);
}
The fix: Stream, don't buffer
For large files on iOS, never read the entire thing into memory. Use Blob.slice() to process in chunks (with the Safari fixes from Gotcha #2, obviously):
async function processLargeFile(file) {
const CHUNK = 10 * 1024 * 1024; // 10MB at a time
const hash = await crypto.subtle.digest('SHA-256', new ArrayBuffer(0));
// FIXME: crypto.subtle can't do incremental hashing natively
// You'll need a JS implementation like js-sha256 for streaming hash
let offset = 0;
while (offset < file.size) {
const slice = file.slice(offset, Math.min(offset + CHUNK, file.size));
const chunk = await slice.arrayBuffer();
// Process chunk — don't accumulate
await sendChunk(chunk, offset, file.size);
offset += CHUNK;
// Let the GC breathe between chunks
// (Yes, this actually helps on Safari. I was surprised too.)
await new Promise((r) => setTimeout(r, 0));
}
}
That setTimeout(r, 0) between chunks looks silly. It isn't. It yields to the event loop, which gives Safari's GC a chance to collect the previous chunk's ArrayBuffer before you allocate the next one. Without it, you get a sawtooth memory pattern that eventually hits the ceiling. With it, memory stays roughly flat.
iOS limits worth knowing
| Device RAM | Approximate tab memory limit | Safe file size for arrayBuffer() | |-----------|------------------------------|----------------------------------| | 3GB (SE 2, iPhone 11) | ~1.2–1.5GB | < 400MB | | 4GB (iPhone 13/14) | ~2GB | < 800MB | | 6GB (iPhone 14 Pro+) | ~3GB | < 1.2GB | | 8GB (iPhone 15 Pro Max) | ~4GB | < 1.5GB |
These are rough — I tested on the devices I had access to, not in a proper lab. Your mileage will vary depending on what else is loaded in the page. Point is: don't assume you can arrayBuffer() anything over a few hundred MB on mobile.
For upload testing, grab files at realistic sizes: a 1MB PDF to verify the happy path, then a 100MB test file to stress the chunking logic. If you can test with a 500MB file on an actual iPhone (not the simulator — the simulator doesn't enforce real memory limits), do it.
The Overarching Lesson
The Browser File API was designed for "user picks a file, form submits it." The use cases we throw at it today — chunked uploads, client-side image processing, streaming hash verification, 200-photo batch previews — were never the original design goal. The API mostly works for these things, but the edges are sharp and browser-specific.
My rules of thumb after burning too many weekends:
- Never
readAsDataURLfor previews. UsecreateObjectURL+revokeObjectURL. Always. - Verify chunk sizes on Safari. Log the expected vs actual
Blob.slice()size. If they don't match, re-slice. - Don't trust
lastModified. It's a hint, not a guarantee. Read EXIF for real dates. - Reset file inputs after reading.
input.value = ''after you grab the File reference. - Stream large files on mobile. If you call
.arrayBuffer()on anything over 100MB on iOS, you're gambling with tab survival.
Test with real files at realistic sizes — not the 50KB placeholder you used during development. TrueFileSize has sample JPGs, PDFs, and large test files specifically for this. Your demo data won't surface these bugs. Production-size files will.
See also: Fix Corrupt File Upload Errors · Large File Upload Performance · Testing File Upload with Playwright