<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hankin</title>
    <description>The latest articles on DEV Community by Hankin (@jinchaoh).</description>
    <link>https://dev.to/jinchaoh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4144783%2F33e96524-f20f-4371-af09-ee6d642028fb.jpg</url>
      <title>DEV Community: Hankin</title>
      <link>https://dev.to/jinchaoh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jinchaoh"/>
    <language>en</language>
    <item>
      <title>Hooking URL.createObjectURL: How My Chrome Extension Saves Instagram Voice Notes as They Play</title>
      <dc:creator>Hankin</dc:creator>
      <pubDate>Sat, 26 Sep 2026 19:37:34 +0000</pubDate>
      <link>https://dev.to/jinchaoh/hooking-urlcreateobjecturl-how-my-chrome-extension-saves-instagram-voice-notes-as-they-play-4e7c</link>
      <guid>https://dev.to/jinchaoh/hooking-urlcreateobjecturl-how-my-chrome-extension-saves-instagram-voice-notes-as-they-play-4e7c</guid>
      <description>&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key insight: Instagram plays voice notes from blob URLs
&lt;/h2&gt;

&lt;p&gt;When Instagram loads a voice message, it fetches the audio from its CDN, wraps it in a &lt;strong&gt;Blob&lt;/strong&gt;, and hands it to the &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element via &lt;strong&gt;URL.createObjectURL(blob)&lt;/strong&gt;. That is the hook point.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Runs at document_start, world: "MAIN"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nativeCreateObjectURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createObjectURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nativeCreateObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;looksLikeAudioBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;emitMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// notify the extension&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manifest V3 worlds: the cross-world bridge problem
&lt;/h2&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;inject.js&lt;/strong&gt; — &lt;code&gt;world: "MAIN"&lt;/code&gt;, &lt;code&gt;run_at: document_start&lt;/code&gt;. Hooks &lt;code&gt;createObjectURL&lt;/code&gt;, sniffs metadata, exposes a small download trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;content.js&lt;/strong&gt; — default isolated world, &lt;code&gt;document_idle&lt;/code&gt;. Bridges everything to the background service worker over &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They talk through &lt;strong&gt;CustomEvents on &lt;code&gt;document&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MAIN world → isolated world&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;adfiAudioMetaReady&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastModified&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// isolated world → background.js&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AUDIO_META&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering blobs without hanging the page
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_VALID_AUDIO_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_VALID_AUDIO_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;52428800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 50 MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Lesson: &lt;strong&gt;on a page you do not control, every byte you touch is a byte someone else's render loop also wants.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sniffing duration without leaking memory
&lt;/h2&gt;

&lt;p&gt;To show "3:47" next to each clip, I decode metadata with a hidden &lt;code&gt;Audio&lt;/code&gt; element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sniffDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// release the blob URL, or the element leaks it forever&lt;/span&gt;
      &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loadedmetadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Pairing blobs with UI bubbles
&lt;/h2&gt;

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

&lt;p&gt;Two performance notes from the trenches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrap the observer callback in a try/catch and &lt;strong&gt;swallow uncaught errors&lt;/strong&gt;. 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.&lt;/li&gt;
&lt;li&gt;Do not position overlay buttons with &lt;code&gt;top: 50%; transform: translateY(-50%)&lt;/code&gt; on an element whose height changes during React re-renders — the button visibly jitters. Anchor to a stable edge instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The download path
&lt;/h2&gt;

&lt;p&gt;When the user clicks save, the MAIN-world script fetches the blob URL, reads it as a data URL with &lt;strong&gt;FileReader&lt;/strong&gt; (deliberately not &lt;code&gt;createObjectURL&lt;/code&gt;, 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 &lt;code&gt;chrome.downloads&lt;/code&gt; API. The file lands in the user's Downloads folder exactly as Instagram delivered it — no re-encoding, quality untouched.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;side panel&lt;/strong&gt; (the Manifest V3 &lt;code&gt;sidePanel&lt;/code&gt; 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 &lt;code&gt;chrome.alarms&lt;/code&gt; + &lt;code&gt;storage&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hook earlier, filter later.&lt;/strong&gt; The createObjectURL hook fires for everything; cheap synchronous filtering up front saved me from every downstream performance problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CustomEvent bridges are fine.&lt;/strong&gt; I briefly considered &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt; round-trips for cross-world calls; CustomEvents are simpler and synchronous in the direction that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume the DOM will betray you.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest caveat: this kind of tool should stay personal. Capturing a voice note a friend sent &lt;strong&gt;you&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;If you want to see the finished result, the extension is &lt;strong&gt;Download Audio from Instagram&lt;/strong&gt; on the &lt;a href="https://chromewebstore.google.com/detail/download-audio-from-insta/lccbkaabehfdecbamghcnbmodkpkdbjg" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt; — free tier included, and the side panel is honestly a nice place to watch the architecture described above run in real time.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
