Converting a Blob URL into a Shareable File

This guide is part of Sharing Files and Media Payloads, which itself is part of Native Device Integration Patterns.

Blob URLs are everywhere in a modern editor: an image preview from URL.createObjectURL, a cropped result, a recorded audio clip, a generated document held in memory. All of them look like URLs, which makes it tempting to pass one to navigator.share({ url }). That never works — a blob: URL is scoped to the document that created it, so the recipient has nothing to fetch. The bytes have to travel, not the reference.

Feature Detection Gate

Validate with the actual file you are about to build, since support depends on its type.

// blob-share-support.js
export function canShareFile(file) {
  if (window.isSecureContext !== true) return false;
  if (typeof navigator.share !== 'function') return false;
  if (typeof navigator.canShare !== 'function') return false;
  if (!(file instanceof File)) return false;

  try {
    return navigator.canShare({ files: [file] });
  } catch {
    return false;
  }
}
Reference Versus Bytes On the left, the page holds a blob in memory and a blob URL pointing at it. Sharing the URL sends a reference that resolves only inside that document, so the recipient sees a broken link. On the right, fetching the URL back into a File sends the actual bytes, which the recipient can open. This document Blob in memory blob:https://…/9f2c the URL means nothing elsewhere share({ url }) recipient: broken link fetch(blobUrl) → File bytes, name, MIME type share({ files }) recipient opens the file nothing to resolve scope ended with the page

Step 1 — Fetch the Blob URL Back into Bytes

fetch resolves a blob: URL against the in-memory blob store, so this costs no network traffic — it is simply the cleanest way to read bytes you already hold.

// blob-url-to-file.js
export async function blobUrlToFile(blobUrl, name, type) {
  const res = await fetch(blobUrl);
  if (!res.ok) throw new Error(`blob URL is no longer valid: ${blobUrl}`);

  const blob = await res.blob();
  const resolvedType = type || blob.type || guessTypeFromName(name);
  if (!resolvedType) throw new Error(`cannot determine a MIME type for ${name}`);

  return new File([blob], name, { type: resolvedType, lastModified: Date.now() });
}

function guessTypeFromName(name = '') {
  const ext = name.split('.').pop().toLowerCase();
  return {
    png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp',
    gif: 'image/gif', pdf: 'application/pdf', mp4: 'video/mp4', webm: 'video/webm',
    mp3: 'audio/mpeg', wav: 'audio/wav', txt: 'text/plain', json: 'application/json'
  }[ext] || '';
}

A failed fetch here has one overwhelmingly likely cause: the URL was already revoked. That is worth a distinct error message, because the alternative — a generic network failure — sends people looking at their connection.

Where you still hold the original Blob, skip the round trip entirely:

// prefer-the-blob.js
export function blobToFile(blob, name, type = blob.type) {
  if (!type) throw new Error(`missing MIME type for ${name}`);
  return new File([blob], name, { type, lastModified: Date.now() });
}

Keeping a reference to the Blob alongside the URL is the better architecture: the URL is for display, the blob is for sharing, and neither has to be reconstructed from the other.

Step 2 — Give the File a Name Users Will Recognise

Object URLs end in a UUID. If that becomes the filename, the recipient receives 9f2c1a8e-… with no extension and no way to open it.

// naming.js
export function shareName(base, type) {
  const ext = {
    'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp',
    'application/pdf': 'pdf', 'video/mp4': 'mp4', 'audio/wav': 'wav'
  }[type] || 'bin';

  const safe = base
    .replace(/[^\w\- ]+/g, '')     // strip characters some filesystems reject
    .trim()
    .slice(0, 60) || 'shared-file';

  return `${safe}.${ext}`;
}

Deriving the extension from the MIME type rather than from the original URL keeps the two consistent, which matters because some receiving apps dispatch on the extension and others on the declared type.

Step 3 — Share, Then Revoke

Preparation happens ahead of the gesture; the handler only shares.

// share-preview.js
import { blobUrlToFile } from './blob-url-to-file.js';
import { canShareFile } from './blob-share-support.js';
import { shareName } from './naming.js';

export function createPreviewSharer(blobUrl, { base = 'preview', type = 'image/png' } = {}) {
  let file = null;

  const prepare = async () => {
    file = await blobUrlToFile(blobUrl, shareName(base, type), type);
  };

  async function share() {
    if (!file) return { outcome: 'fallback', reason: 'not-ready' };
    if (!canShareFile(file)) return { outcome: 'fallback', reason: 'unsupported' };

    try {
      await navigator.share({ files: [file], title: base });
      return { outcome: 'shared' };
    } catch (err) {
      if (err.name === 'AbortError') return { outcome: 'dismissed' };
      return { outcome: 'fallback', reason: err.name };
    }
  }

  function dispose() {
    URL.revokeObjectURL(blobUrl);
    file = null;
  }

  return { prepare, share, dispose };
}

dispose is the part teams forget. Every unrevoked object URL pins its blob for the life of the document, so an editor that previews twenty photos holds all twenty in memory indefinitely — and on a phone that ends as a tab discard the user reads as a crash.

Object URL Lifetime and Memory A timeline from createObjectURL through display and sharing to revokeObjectURL. The blob is retained across the whole span; without the final revoke the retention continues for the lifetime of the document and accumulates with every preview. createObjectURL blob retained preview shown img src = blob:… fetch → File shared as bytes revoke freed retained Without the revoke every previewed item stays in memory for the document's lifetime — the tab is eventually discarded Keep the Blob, Not Only the URL Holding only the object URL forces a fetch to recover the bytes and breaks entirely once the URL is revoked. Keeping the Blob alongside the URL means the preview and the share draw on the same source with no recovery step. URL only preview: img.src = blobUrl share: fetch(blobUrl) first revoked early → fetch fails lifetime coupling you did not ask for Blob + URL together preview: img.src = blobUrl share: new File([blob], name) revoke whenever the preview ends no round trip, no ordering hazard

Failure Modes and Recovery

Symptom Cause Minimal fix
Recipient gets an unopenable link blob: URL passed as url Send bytes in files instead
fetch on the blob URL fails URL already revoked Keep the original Blob, or revoke later
File arrives named like a UUID Object URL used as the filename Derive a readable name plus extension
canShare throws Blob passed where a File was required Wrap with the File constructor
Memory grows while browsing Object URLs never revoked revokeObjectURL when the preview is discarded
Type is empty on the receiving side Relied on blob.type from a cached source Pass an explicit type to the constructor

Browser and Platform Caveat

URL.createObjectURL, fetching a blob: URL, and the File constructor all behave consistently across Chrome, Safari, Firefox and Edge, so the conversion itself is portable; only the sharing step varies by platform. Two practical notes. Object URLs are bound to the document that created them, which means one minted inside a service worker or a different window is not usable in the page — pass the Blob across contexts instead, exactly as the share queue does when it stores payloads. And iOS Safari is the most aggressive about discarding memory-heavy tabs, so revocation discipline matters more there than anywhere else: an editor that leaks a few dozen full-resolution previews will be reloaded underneath the user mid-session.

FAQ

Can I pass a blob URL string to navigator.share as a url?

No. A blob: URL is meaningful only inside the document that created it, so a receiving app has nothing to resolve. Some browsers reject it outright and others share a link the recipient cannot open. Send the bytes as a File in the files array instead.

Does fetching a blob URL cost a network request?

No. A blob: URL resolves against the in-memory blob store, so fetch returns the existing bytes without touching the network. It is effectively a structured way to read a blob you already hold.

What happens if I forget URL.revokeObjectURL?

The blob stays alive for the lifetime of the document, so a gallery or editor that mints a URL per item retains every byte the user has viewed. Memory grows steadily and, on a phone, the tab is eventually discarded — which looks like a random crash rather than a leak.