Storing File Payloads as ArrayBuffer in IndexedDB
This guide is part of IndexedDB Persistence for Offline Share Queues, which itself is part of Offline-First PWA Patterns for Web Share API.
Storing a File in IndexedDB looks like it works — the record writes without error and reads back with the right name — but the file’s bytes can be released when the service worker or page is torn down, leaving a reference that deserialises into an unusable file. When the queue flushes on reconnect and hands that file to navigator.share(), the call throws a DataError. The fix is to persist the raw bytes as an ArrayBuffer with their metadata, then reconstruct a fresh File at flush time.
Feature Detection Gate
Files add a second requirement beyond navigator.share: the platform must accept file payloads, which only navigator.canShare({ files }) can tell you. Gate on both before you serialise anything.
// file-share-gate.js
export function canQueueFileShare(files) {
if (!window.isSecureContext) return false;
if (typeof navigator.share !== 'function') return false;
// File sharing support is separate from text/url support.
if (typeof navigator.canShare !== 'function') return false;
return navigator.canShare({ files });
}
If this returns false, do not queue the file — the record could never flush successfully. Strip the file and queue a text/url-only entry, or route to a link-based fallback such as resolving DataError when sharing files.
Solution Walkthrough
Step 1 — Serialise the File to a self-contained record
Read the bytes with await file.arrayBuffer() and capture the metadata an ArrayBuffer cannot carry on its own. Do this before opening the IndexedDB transaction — arrayBuffer() is async, and awaiting it inside an open transaction would let the transaction auto-commit and throw TransactionInactiveError.
// serialise-files.js
export async function serialiseFiles(fileList) {
const files = Array.from(fileList ?? []);
return Promise.all(
files.map(async (file) => ({
buffer: await file.arrayBuffer(), // structured-clonable, durable bytes
name: file.name,
type: file.type, // MIME — lost if you drop it
lastModified: file.lastModified
}))
);
}
The returned array is a set of plain objects containing only an ArrayBuffer and primitives. That is exactly what the structured clone algorithm serialises durably, so once written it survives page reloads and service worker restarts intact.
A Blob (for example a canvas export) has the same problem as a File — it is clonable but its bytes can be released — so serialise it the same way, synthesising a name and reading blob.type. Because each buffer occupies its full uncompressed byte length in the store, budget the whole record, not each file in isolation: sum buffer.byteLength across the array and reject the entry before writing if the total exceeds your cap, so a multi-file share cannot quietly blow past quota.
// budget-files.js
const MAX_TOTAL_BYTES = 8 * 1024 * 1024; // whole-record ceiling
export function totalBytes(serialisedFiles = []) {
return serialisedFiles.reduce((n, f) => n + f.buffer.byteLength, 0);
}
export function assertWithinBudget(serialisedFiles) {
const bytes = totalBytes(serialisedFiles);
if (bytes > MAX_TOTAL_BYTES) {
throw new RangeError(`Serialised files total ${bytes} bytes, over the ${MAX_TOTAL_BYTES} cap.`);
}
return bytes;
}
Step 2 — Write the record, then reconstruct on flush
Store the serialised files as part of the queue record. When the flush runs — potentially in a service worker minutes or hours later — rebuild each File from its buffer and metadata.
// reconstruct-files.js
export function reconstructFiles(serialisedFiles = []) {
return serialisedFiles.map(
(f) => new File([f.buffer], f.name, {
type: f.type,
lastModified: f.lastModified
})
);
}
Wrapping [f.buffer] as the file body and passing { type, lastModified } restores a File indistinguishable from the original for sharing purposes. The name and type matter: share targets route on MIME type, and a file that arrives as application/octet-stream because the type was dropped may be rejected or mishandled by the receiving app.
Step 3 — Validate the rebuilt file before sharing
The OS share-target list can change between capture and flush — an app that handled the MIME type at enqueue time may be uninstalled by the time the queue drains. Re-validate the reconstructed payload immediately before navigator.share().
// flush-file-share.js
import { reconstructFiles } from './reconstruct-files.js';
export async function flushFileShare(record) {
if (!window.isSecureContext || typeof navigator.share !== 'function') {
return { shared: false, reason: 'unsupported' };
}
const files = reconstructFiles(record.payload.files);
const data = {
title: record.payload.title,
text: record.payload.text,
url: record.payload.url,
files
};
// Re-check now — support may have changed since enqueue.
if (typeof navigator.canShare === 'function' && !navigator.canShare(data)) {
return { shared: false, reason: 'canShare-rejected' };
}
try {
await navigator.share(data);
return { shared: true };
} catch (err) {
if (err.name === 'AbortError') return { shared: false, reason: 'dismissed' };
return { shared: false, reason: err.name };
}
}
A rebuilt file that fails navigator.canShare() should not be dispatched — mark the record failed (or strip the file and retry text-only) rather than letting navigator.share() throw a DataError. This is the same validation boundary the persistence guide applies to every record on read.
Failure Modes and Recovery
| Symptom | Root cause | Minimal fix |
|---|---|---|
DataError on flush |
Stored the File/Blob by reference; bytes detached after SW restart |
Serialise with await file.arrayBuffer() and store { buffer, name, type, lastModified } |
| Shared file arrives with no/wrong type | Dropped type when serialising; reconstruct defaulted to empty MIME |
Persist and restore type in the reconstruct call |
navigator.canShare() returns false after rebuild |
Missing type, or target for that MIME no longer installed |
Re-validate before share; strip file and fall back to text/url |
QuotaExceededError on write |
ArrayBuffer stores full uncompressed bytes; large files exhaust quota |
Enforce per-entry byte cap; check navigator.storage.estimate(); route large files to cloud upload |
TransactionInactiveError |
Awaited file.arrayBuffer() inside the open transaction |
Serialise before calling db.transaction(); keep the transaction body synchronous |
File is not defined in worker flush |
Reconstructing in a context without the File constructor |
File is available in service workers; ensure you are not in a plain Node/module scope |
Browser and Platform Caveat
The ArrayBuffer round-trip works identically across Chromium, Safari, and Firefox because all three use the same structured clone algorithm for IndexedDB values. The variance is in quota and eviction, not serialisation: Safari caps origin storage far lower than Chromium and can evict it after seven idle days, so keep queued files small and flush early on iOS. File.prototype.arrayBuffer() is available wherever the Web Share API is (Chromium 76+, Safari 14+, Firefox 90+), so no polyfill is needed in practice. In service workers the File constructor is present, so reconstruction works in the flush handler as well as the foreground.
FAQ
Can I store a File object directly in IndexedDB?
The write usually succeeds because File is structured-cloneable, but the stored reference can point at bytes the browser releases when the service worker or page is torn down. Reading it back and passing it to navigator.share() then throws DataError. Store the bytes as an ArrayBuffer instead so the record is self-contained and survives restarts.
Do I lose the MIME type when storing an ArrayBuffer?
Yes, unless you store it separately — an ArrayBuffer carries no metadata. Persist { buffer, name, type, lastModified } together. If you drop type, the reconstructed File defaults to an empty MIME type and navigator.canShare() may reject it or the receiving app may mishandle it.
How do I rebuild a File from a stored ArrayBuffer?
Call new File([buffer], name, { type, lastModified }). The buffer becomes the file body and the third argument restores the metadata. Then validate with navigator.canShare({ files: [rebuilt] }) before sharing, since OS support for the MIME type can change between capture and flush.
Will large files blow the storage quota?
Yes. An ArrayBuffer occupies the full uncompressed byte length of the file, so a few large images can exhaust an origin’s quota. Enforce a per-entry byte cap, check navigator.storage.estimate() before writing, and route oversized files to a cloud-upload or link fallback instead of the queue.