DEV Community

Hankin
Hankin

Posted on

Hooking URL.createObjectURL: How My Chrome Extension Saves Instagram Voice Notes as They Play

Instagram still does not let you save voice messages from DMs. No download button, no archive, nothing. If a friend sends you a voice note worth keeping — or you hear a great sound on a Reels audio page — your options are screen recording (with UI noise, notifications popping in, and re-encoded quality) or third-party downloader sites (which usually need the message link and often do not work for DM voice notes at all).

I wanted the file the moment I heard it, at original quality, without leaving the page. So I built a small Chrome extension that does exactly that, and this post is about the parts of the engineering that turned out to be interesting. None of this requires scraping or private APIs — everything below runs inside your own logged-in browser session.

The key insight: Instagram plays voice notes from blob URLs

When Instagram loads a voice message, it fetches the audio from its CDN, wraps it in a Blob, and hands it to the <audio> element via URL.createObjectURL(blob). That is the hook point.

Instead of watching the DOM for media elements (fragile — Instagram re-renders constantly) or trying to intercept network traffic, my extension hooks URL.createObjectURL itself, in the page's JavaScript context, before Instagram's own code runs:

// Runs at document_start, world: "MAIN"
const nativeCreateObjectURL = URL.createObjectURL.bind(URL);
URL.createObjectURL = function (obj) {
  const url = nativeCreateObjectURL(obj);
  if (looksLikeAudioBlob(obj)) {
    emitMeta(url, null, obj.lastModified); // notify the extension
  }
  return url;
};
Enter fullscreen mode Exit fullscreen mode

Now every voice note that plays is announced to my code with its blob URL — before I even know which chat bubble it belongs to.

Manifest V3 worlds: the cross-world bridge problem

Here is the first gotcha. A content script declared with "world": "MAIN" runs in the page's JS context — required to patch URL.createObjectURL where Instagram's code sees it. But MAIN-world scripts have no access to chrome.runtime APIs. So the extension uses two scripts:

  1. inject.js — world: "MAIN", run_at: document_start. Hooks createObjectURL, sniffs metadata, exposes a small download trigger.
  2. content.js — default isolated world, document_idle. Bridges everything to the background service worker over chrome.runtime.sendMessage.

They talk through CustomEvents on document:

// MAIN world → isolated world
document.dispatchEvent(new CustomEvent('adfiAudioMetaReady', {
  detail: { url, duration, lastModified }
}));

// isolated world → background.js
chrome.runtime.sendMessage({ type: 'AUDIO_META', payload });
Enter fullscreen mode Exit fullscreen mode

Three event types cover the whole protocol: meta-ready, duration-request, and trigger-download. Crude, but it survives every Instagram re-render because it does not depend on the DOM at all.

Filtering blobs without hanging the page

Not every blob is a voice note. Reels pages create video blobs, poster images, and more — sometimes hundreds per second during hydration. My filter checks MIME type (must contain audio, mp3, or mpeg) and size:

const MIN_VALID_AUDIO_SIZE = 2048;
const MAX_VALID_AUDIO_SIZE = 52428800; // 50 MB
Enter fullscreen mode Exit fullscreen mode

That 50 MB cap is not arbitrary. I originally set it to 200 MB and hashed blobs to fingerprint them. On a busy Reels page that pegged the main thread for roughly a second per digest while blobs queued up behind it — a genuine page hang. Dropping the ceiling to 50 MB (DM voice notes are under 5 MB, Reels audio under 20 MB) made the filter cheap enough that hashing became unnecessary.

Lesson: on a page you do not control, every byte you touch is a byte someone else's render loop also wants.

Sniffing duration without leaking memory

To show "3:47" next to each clip, I decode metadata with a hidden Audio element:

function sniffDuration(url) {
  return new Promise((resolve) => {
    const a = new Audio();
    const done = (sec) => {
      // release the blob URL, or the element leaks it forever
      a.removeAttribute('src');
      a.load();
      resolve(sec);
    };
    a.preload = 'metadata';
    a.addEventListener('loadedmetadata', () => done(a.duration));
    a.addEventListener('error', () => done(0));
    a.src = url;
  });
}
Enter fullscreen mode Exit fullscreen mode

The cleanup lines matter: without removeAttribute('src') + load(), every sniffed voice note leaves a live Audio element holding a reference to its blob URL. In a busy DM thread that was dozens of leaked elements per minute.

Pairing blobs with UI bubbles

A blob URL alone is not useful — the user wants a download button next to the voice bubble they are looking at. For that, an isolated-world MutationObserver watches the chat container, finds new voice bubbles, and matches them to captured blobs by arrival order and timestamp. On Reels audio pages (/reels/audio/), the same button gets anchored next to the play row.

Two performance notes from the trenches:

  • Wrap the observer callback in a try/catch and swallow uncaught errors. A throwing handler inside a high-frequency MutationObserver on a busy page can peg the main thread and trigger Chrome's "page unresponsive" dialog before the user sees anything.
  • Do not position overlay buttons with top: 50%; transform: translateY(-50%) on an element whose height changes during React re-renders — the button visibly jitters. Anchor to a stable edge instead.

The download path

When the user clicks save, the MAIN-world script fetches the blob URL, reads it as a data URL with FileReader (deliberately not createObjectURL, to avoid re-triggering my own hook and creating duplicates), and hands the bytes to the background service worker, which writes the file through the chrome.downloads API. The file lands in the user's Downloads folder exactly as Instagram delivered it — no re-encoding, quality untouched.

A side panel (the Manifest V3 sidePanel API, Chrome 114+) acts as an inbox: every clip captured in the current conversation shows up with inline playback and a Download All button. A master switch pauses capture, and a daily free quota resets at midnight UTC via chrome.alarms + storage.

What I would do differently

  • Hook earlier, filter later. The createObjectURL hook fires for everything; cheap synchronous filtering up front saved me from every downstream performance problem.
  • CustomEvent bridges are fine. I briefly considered chrome.scripting.executeScript round-trips for cross-world calls; CustomEvents are simpler and synchronous in the direction that matters.
  • Assume the DOM will betray you. The blob-level hook survived every Instagram UI redesign so far; my DOM-level code did not. Keep the DOM layer as thin as possible.

One honest caveat: this kind of tool should stay personal. Capturing a voice note a friend sent you is one thing; republishing someone's words without consent is not okay. The extension only ever sees media your own session can already play — nothing more.

If you want to see the finished result, the extension is Download Audio from Instagram on the Chrome Web Store — free tier included, and the side panel is honestly a nice place to watch the architecture described above run in real time.

Top comments (1)

Collapse
 
devsupport profile image
Dev Support •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

​