The three earlier Instagram posts — photo saving, reel downloading, and story downloading — each require you to open the browser console, paste a snippet, and press Enter every single time you want to save something. This post bundles all three techniques into one persistent userscript that injects a ⬇ button directly into the Instagram UI — no console, no paste, no repeat setup.
Bookmarklets vs. Userscripts
The previous posts use bookmarklets: a piece of JavaScript stored as a browser bookmark. You navigate to the page, tap the bookmark, and the script runs once. It is a great tool for occasional one-off tasks, but it has friction: you have to remember to trigger it, and it vanishes the moment the page navigates away.
A userscript is different. It is a JavaScript file managed by a browser extension called a userscript manager. You install the script once, and the manager automatically injects it into every matching page (here, every instagram.com URL) before the page’s own scripts run. The script stays active across navigations, handles Instagram’s infinite-scroll feed, and requires zero manual interaction. The ⬇ button is just there.
| Bookmarklet | Userscript | |
|---|---|---|
| How it runs | You trigger it manually each visit | Auto-injected on page load |
| Persists across navigation | ❌ Re-run per page | ✅ Survives SPA navigation |
| Mobile support | ⚠️ Must be set up as a bookmark and tapped each time | ✅ Works automatically with Firefox for Android, Kiwi Browser, or iOS Userscripts |
| Setup | One bookmark per script | One manager + one install |
| Best for | One-time tasks | Recurring enhancements |
Choosing a Userscript Manager
A userscript manager is a browser extension that stores, manages, and injects your scripts. There are several options — the right one depends on your browser and platform.
Desktop
| Manager | Chrome / Edge / Brave | Firefox | Safari (macOS) | Opera |
|---|---|---|---|---|
| Tampermonkey | ✅ | ✅ | ✅ (via App Store) | ✅ |
| Violentmonkey | ✅ | ✅ | ❌ | ✅ |
| Greasemonkey | ❌ | ✅ | ❌ | ❌ |
Tampermonkey is the most widely used, has the richest feature set (script sync, update checking, a polished dashboard), and is well-maintained. It is the safe default for most users.
Violentmonkey is fully open-source and has no telemetry. If you prefer to audit every line of the extension code, Violentmonkey is the better choice. Feature parity with Tampermonkey is high.
Greasemonkey is the original userscript manager — it pioneered the concept. It is Firefox-only and has fallen behind Tampermonkey and Violentmonkey in features, so it is recommended only if you already have it installed and are comfortable with it.
Mobile
Mobile browsers traditionally block extensions entirely, which rules out userscript managers. However, a few options exist:
| Platform | Option | Notes |
|---|---|---|
| Android | Firefox for Android + Tampermonkey or Violentmonkey | Firefox on Android supports the full AMO extension catalogue, including both managers. Best mobile option on Android. |
| Android | Kiwi Browser + Tampermonkey | Kiwi is a Chromium-based browser that accepts Chrome extensions. Install Tampermonkey from the Chrome Web Store, then install the script normally. |
| iOS / iPadOS | Userscripts + Safari | A free, open-source Safari extension from the App Store. Supports Greasemonkey-compatible @match / @grant headers. |
| iOS / iPadOS | Hyperweb + Safari | An alternative paid Safari extension manager with a polished UI. |
Desktop Chrome/Edge/Brave/Opera → Tampermonkey or Violentmonkey
Desktop Firefox → Tampermonkey or Violentmonkey
Desktop Safari → Tampermonkey (App Store)
Android → Firefox for Android + Tampermonkey
iOS / iPadOS → Userscripts + Safari
Installing the Script in Tampermonkey
The steps are nearly identical for Violentmonkey and Userscripts — swap the extension name where appropriate.
Step 1 — Install Tampermonkey
Go to the extension store for your browser and install Tampermonkey. After installation a small icon (a circle with two overlapping dots) appears in your toolbar.
Step 2 — Create a New Script
Click the Tampermonkey icon and choose Create a new script… (or Dashboard → + New Script). The script editor opens with a default template.
Step 3 — Paste the Script
Delete the default template entirely and paste the full script below. Then click the Save button (or press Ctrl + S / Cmd + S).
Step 4 — Open Instagram
Navigate to instagram.com. Browse your feed, open a Reel, or visit someone’s Story. The ⬇ button appears automatically in the top-right corner of each piece of media. Click it to download.
The Script
// ==UserScript==
// @name Instagram Download Buttons
// @namespace https://mathewsachin.github.io/
// @version 1.0
// @description Adds ⬇ download buttons for reels and stories on Instagram; restores right-click on photos
// @author Mathew Sachin
// @match https://www.instagram.com/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect cdninstagram.com
// @connect instagram.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* Class added to every button we create — used to filter our own */
/* DOM insertions out of the MutationObserver, preventing a feedback */
/* loop that would hang scrolling. */
const BTN_CLASS = 'ig-dl-btn';
const BTN_STYLE = [
'background:rgba(0,0,0,0.6)',
'border:none',
'border-radius:6px',
'color:white',
'cursor:pointer',
'font-size:18px',
'line-height:1',
'padding:6px 8px',
'position:absolute',
'z-index:9999',
].join(';');
/* ── Direct download via GM_xmlhttpRequest ───────────────────────── */
/* Fetches the CDN URL from the extension context (bypasses CORS), */
/* creates a same-origin blob URL, and triggers <a download> so the */
/* file saves directly instead of opening in a new tab. */
function downloadBlob(url, filename) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
onload: r => {
const blobUrl = URL.createObjectURL(r.response);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
resolve();
},
onerror: reject,
});
});
}
/* ── Shared React Fiber scraper ─────────────────────────────────── */
/* Iterative DFS: yields every 200 object visits so the browser can */
/* process scroll / paint events between bursts of work. WeakSet */
/* guards against React's circular fiber references. */
async function scrapeReactFiber(startEl) {
const videoUrls = [], imageUrls = [];
const visited = new WeakSet();
let el = startEl;
while (el) {
const fk = Object.keys(el).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactProps$'));
if (fk) {
const stack = [{ obj: el[fk], depth: 0 }];
let steps = 0;
while (stack.length > 0) {
// Yield every 200 object visits to keep the thread responsive
if (++steps % 200 === 0) await new Promise(r => setTimeout(r, 0));
const { obj, depth } = stack.pop();
if (depth > 15 || !obj || typeof obj !== 'object' || obj instanceof HTMLElement) continue;
if (visited.has(obj)) continue;
visited.add(obj);
for (const key in obj) {
const val = obj[key];
if (typeof val === 'string' && val.startsWith('https://') && !val.includes('<?xml')) {
if (val.includes('.mp4'))
videoUrls.push({ url: val, area: (obj.width || 0) * (obj.height || 0) });
else if (val.includes('.jpg') || val.includes('.webp'))
imageUrls.push({ url: val, area: (obj.width || 0) * (obj.height || 0) });
} else if (val && typeof val === 'object') {
stack.push({ obj: val, depth: depth + 1 });
}
}
}
// Found URLs at this ancestor — no need to walk further up the tree
if (videoUrls.length > 0 || imageUrls.length > 0) break;
}
el = el.parentElement;
await new Promise(r => setTimeout(r, 0)); // yield between ancestors
}
const best = arr => arr.length ? arr.sort((a, b) => b.area - a.area)[0].url : null;
return videoUrls.length > 0 ? best(videoUrls) : best(imageUrls);
}
/* ── MediaRecorder fallback for reels when CDN URL is not in Fiber ─ */
function recordReel(videoEl) {
return new Promise((resolve, reject) => {
videoEl.muted = true;
videoEl.pause();
videoEl.currentTime = 0;
setTimeout(() => {
let stream;
try { stream = videoEl.captureStream(); }
catch (e) { return reject(new Error('captureStream not supported')); }
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: 'video/webm';
const chunks = [];
const rec = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 5_000_000 });
rec.ondataavailable = e => e.data.size > 0 && chunks.push(e.data);
rec.onstop = () => resolve(URL.createObjectURL(new Blob(chunks, { type: mimeType })));
videoEl.play();
rec.start();
videoEl.addEventListener('ended', () => rec.stop(), { once: true });
}, 400);
});
}
/* ── Button factory ──────────────────────────────────────────────── */
function makeBtn(emoji, title, onClick) {
const btn = document.createElement('button');
btn.textContent = emoji;
btn.title = title;
btn.className = BTN_CLASS; // marks button so MutationObserver ignores it
btn.style.cssText = BTN_STYLE;
btn.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); onClick(btn); });
return btn;
}
/* ── Guess file extension from a CDN URL ────────────────────────── */
function extFromUrl(url) {
const m = url.match(/\.(mp4|jpg|jpeg|webp|png)(\?|$)/i);
return m ? m[1] : 'jpg';
}
/* ── CSS shield removal — restores right-click on all images ─────── */
/* GM_addStyle injects via extension context (CSP-exempt). Setting */
/* position:relative + z-index:999 lifts <img> elements above the */
/* invisible overlay <div>s Instagram stacks on top to intercept */
/* mouse events, so right-click lands directly on the image. */
let _shieldsInstalled = false;
function installKillShieldsCSS() {
if (_shieldsInstalled) return;
_shieldsInstalled = true;
GM_addStyle('img { pointer-events: auto !important; user-select: auto !important; position: relative !important; z-index: 999 !important; }');
}
/* ══ REEL HANDLER — Reel player ══════════════════════════════════════ */
function addReelButton(videoEl) {
if (videoEl.dataset.igDl) return;
videoEl.dataset.igDl = '1';
const wrapper = videoEl.parentElement;
if (!wrapper) return;
const pos = getComputedStyle(wrapper).position;
if (!['relative', 'absolute', 'fixed', 'sticky'].includes(pos))
wrapper.style.setProperty('position', 'relative', 'important');
const btn = makeBtn('⬇', 'Download reel', async () => {
btn.disabled = true;
btn.textContent = '⏳';
try {
const url = await scrapeReactFiber(videoEl);
if (url) {
// Fiber gave us the CDN URL — download it directly
await downloadBlob(url, `reel.${extFromUrl(url)}`);
} else {
// MediaRecorder path: play through and record frames
const blobUrl = await recordReel(videoEl);
const a = document.createElement('a');
a.href = blobUrl;
a.download = 'reel.webm';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
btn.textContent = '✅';
} catch (err) {
btn.textContent = '❌';
console.error('[IG-DL] Reel download failed:', err);
}
setTimeout(() => { btn.textContent = '⬇'; btn.disabled = false; }, 3000);
});
btn.style.top = '8px';
btn.style.right = '8px';
wrapper.appendChild(btn);
}
/* ══ STORY HANDLER — /stories/ URL ══════════════════════════════════ */
function addStoryButton(mediaEl) {
if (mediaEl.dataset.igDl) return;
mediaEl.dataset.igDl = '1';
const wrapper = mediaEl.parentElement;
if (!wrapper) return;
const pos = getComputedStyle(wrapper).position;
if (!['relative', 'absolute', 'fixed', 'sticky'].includes(pos))
wrapper.style.setProperty('position', 'relative', 'important');
const btn = makeBtn('⬇', 'Download story', async () => {
btn.disabled = true;
btn.textContent = '⏳';
try {
const url = await scrapeReactFiber(mediaEl);
if (url) {
await downloadBlob(url, `story.${extFromUrl(url)}`);
btn.textContent = '✅';
} else {
btn.textContent = '❌';
console.warn('[IG-DL] Story CDN URL not found in React Fiber');
}
} catch (err) {
btn.textContent = '❌';
console.error('[IG-DL] Story download failed:', err);
}
setTimeout(() => { btn.textContent = '⬇'; btn.disabled = false; }, 2000);
});
// Place above the story progress bar (~40 px tall) at the bottom of the story
btn.style.bottom = '48px';
btn.style.right = '12px';
wrapper.appendChild(btn);
}
/* ══ PAGE SCANNER — Detect context and add the right buttons ════════ */
function scan() {
installKillShieldsCSS(); // inject once; no-op on subsequent calls
if (location.href.includes('/stories/')) {
// Stories are pre-loaded horizontally; pick the one nearest the viewport centre
const allMedia = Array.from(document.querySelectorAll('video, img'));
const cx = window.innerWidth / 2;
let best = null, minDist = Infinity;
allMedia
.sort((a, b) => (a.tagName === 'VIDEO' ? -1 : 1))
.forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.left >= 0 && r.right <= window.innerWidth) {
const dist = Math.abs(cx - (r.left + r.width / 2));
if (dist < minDist) { minDist = dist; best = el; }
}
});
if (best) addStoryButton(best);
return;
}
// Reel videos (tall, in-viewport)
document.querySelectorAll('video').forEach(v => {
if (v.getBoundingClientRect().height > 100) addReelButton(v);
});
}
/* ══ SPA NAVIGATION WATCHER ══════════════════════════════════════════ */
/* Instagram is a React SPA: URLs change via pushState without a full */
/* page reload. We debounce DOM mutations + intercept history methods */
/* to re-run scan() after each navigation. */
let scanTimer;
/* Use requestIdleCallback when available so scan() runs during idle */
/* time and doesn't compete with scroll animation frames. */
const scheduleIdle = typeof requestIdleCallback === 'function'
? cb => requestIdleCallback(cb, { timeout: 1000 })
: cb => setTimeout(cb, 600);
const scheduleScan = () => {
clearTimeout(scanTimer);
scanTimer = scheduleIdle(scan);
};
/* Only react to mutations caused by Instagram's own code — not by */
/* our own button insertions (which carry BTN_CLASS). Without this */
/* filter, each appendChild(btn) would re-trigger the observer and */
/* reset the debounce timer, causing continuous work during scrolling. */
new MutationObserver(mutations => {
if (mutations.some(m =>
Array.from(m.addedNodes).some(n => n.nodeType === 1 && !n.classList.contains(BTN_CLASS))
)) scheduleScan();
}).observe(document.body, { childList: true, subtree: true });
const _push = history.pushState.bind(history);
history.pushState = (...a) => { _push(...a); scheduleScan(); };
const _replace = history.replaceState.bind(history);
history.replaceState = (...a) => { _replace(...a); scheduleScan(); };
window.addEventListener('popstate', scheduleScan);
scan(); // initial run
})();
How the Script Works
The script is structured in five layers. Each layer builds on techniques introduced in the individual Instagram posts; the userscript simply wires them all together.
The ==UserScript== Header
// @match https://www.instagram.com/*
// @grant GM_xmlhttpRequest
// @connect cdninstagram.com
// @connect instagram.com
// @run-at document-idle
The header is a structured comment that the userscript manager reads — not JavaScript. @match is a URL glob: the manager only injects the script when the current URL matches https://www.instagram.com/*. @run-at document-idle waits until the page’s DOM is ready before injecting (equivalent to DOMContentLoaded).
@grant GM_xmlhttpRequest opts into the special GM_xmlhttpRequest API, which lets the script make HTTP requests from the extension’s background context — bypassing the same-origin restrictions that the page itself faces. This is required for direct downloads (see below). @connect declares which domains GM_xmlhttpRequest is allowed to contact; Tampermonkey matches subdomains, so cdninstagram.com covers scontent-lga3-1.cdninstagram.com and all other Instagram CDN hostnames.
Direct Download via GM_xmlhttpRequest
function downloadBlob(url, filename) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
onload: r => {
const blobUrl = URL.createObjectURL(r.response);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
// ...
},
});
});
}
The <a download> attribute is the standard way to trigger a browser download rather than opening a URL. However, browsers ignore download on cross-origin URLs — if the link points to a different domain (such as Instagram’s CDN), the attribute is silently dropped and the URL opens in a new tab instead.
GM_xmlhttpRequest sidesteps this. Because the request runs from the extension context rather than the page context, it is not subject to the same-origin policy. The CDN response arrives as a Blob; URL.createObjectURL converts it to a same-origin blob: URL, and <a download> works correctly on same-origin URLs. The blob URL is revoked after 60 seconds to free memory.
CSS Shield Removal — Restoring Right-Click
let _shieldsInstalled = false;
function installKillShieldsCSS() {
if (_shieldsInstalled) return;
_shieldsInstalled = true;
GM_addStyle('img { pointer-events: auto !important; user-select: auto !important; position: relative !important; z-index: 999 !important; }');
}
Instagram uses two CSS tricks to block right-click on photos:
pointer-events: noneis set on<img>elements so mouse clicks pass through to a transparent overlay<div>above the image instead of hitting the image itself.- That overlay
<div>sits higher in the stacking order (higher z-index), so even if you restore pointer events on the image, clicks still land on the overlay.
The fix is a single CSS rule injected via GM_addStyle (requires @grant GM_addStyle). Because GM_addStyle injects via the extension context rather than the page context, it bypasses Instagram’s Content Security Policy (CSP) that would otherwise block page-injected <style> elements:
pointer-events: autore-enables mouse events on the image itself.position: relativeestablishes a new stacking context for the image.z-index: 999lifts the image above the overlay in the stacking order, so right-click lands directly on the<img>and the browser shows Save image as…user-select: autore-enables text selection (Instagram also disables this).
The boolean guard _shieldsInstalled ensures the style rule is injected exactly once.
The React Fiber Scraper
async function scrapeReactFiber(startEl) {
const visited = new WeakSet();
// ...
const stack = [{ obj: el[fk], depth: 0 }];
let steps = 0;
while (stack.length > 0) {
if (++steps % 200 === 0) await new Promise(r => setTimeout(r, 0));
const { obj, depth } = stack.pop();
if (visited.has(obj)) continue;
visited.add(obj);
// ...push children onto stack...
}
}
Every DOM element rendered by React has a hidden property whose name starts with __reactFiber$ or __reactProps$ (followed by a random suffix that changes with each React build). This property holds the component’s internal state — including the CDN URLs Instagram retrieved from its servers. The scraper walks up the DOM from the target element, finds these hidden properties, and crawls their object trees looking for .mp4, .jpg, or .webp URLs. This approach is shared by both the photo handler and the story handler; it comes directly from the Story Sniper.
When multiple resolutions are present (Instagram often includes several), the scraper ranks them by width × height and returns the largest.
Why iterative DFS with yields? A single React fiber node can have thousands of nested objects, and walking the whole tree synchronously can block the main thread for hundreds of milliseconds — long enough to drop frames and make the page feel frozen. The scraper uses an explicit stack instead of recursion, and inserts await new Promise(r => setTimeout(r, 0)) every 200 object visits. Each yield hands control back to the browser’s event loop so it can process scroll frames, paints, and input events before the scraper continues. This keeps each burst of synchronous work under ~1 ms.
Why WeakSet? React’s fiber tree contains circular references — parent nodes point to children and children point back to parents. Without cycle detection, a naive DFS would loop infinitely. WeakSet.has / WeakSet.add are O(1) and don’t prevent garbage collection, so they add negligible overhead.
Button Disabling
btn.disabled = true;
btn.textContent = '⏳';
// ... async work ...
setTimeout(() => { btn.textContent = '⬇'; btn.disabled = false; }, 2000);
Every click handler disables its button at the start of the operation and re-enables it in the post-completion setTimeout. This prevents double-clicks from launching overlapping downloads, and — combined with the async fiber scraper — ensures the button gives clear visual feedback (⏳ → ✅ or ❌) without the page becoming unresponsive.
The MediaRecorder Fallback
const blobUrl = await recordReel(videoEl);
The Fiber scraper does not always find a CDN URL for Reels — Instagram sometimes serves the video in encrypted segments or keeps the URL deeper in the fiber tree than the scraper can reach. In that case the Reel handler falls back to the approach from the Reel Sniper: it calls videoEl.captureStream() and records the decoded frames with MediaRecorder (VP9 + Opus, 5 Mbps) as the video plays from the beginning. The result is a .webm file rather than the original .mp4, but it captures whatever the browser is actually displaying.
The Story Axis Fix
const dist = Math.abs(cx - (r.left + r.width / 2));
if (r.left >= 0 && r.right <= window.innerWidth)
Stories pre-load the previous and next stories horizontally — they sit off-screen to the left and right, outside the horizontal viewport bounds. The story handler measures horizontal distance to the viewport centre and only considers elements fully within the horizontal bounds, exactly as the Story Sniper does. This prevents the button from being attached to a pre-loaded off-screen story instead of the one you are actually viewing.
The SPA Navigation Watcher
new MutationObserver(mutations => {
if (mutations.some(m =>
Array.from(m.addedNodes).some(n => n.nodeType === 1 && !n.classList.contains(BTN_CLASS))
)) scheduleScan();
}).observe(document.body, { childList: true, subtree: true });
history.pushState = (...a) => { _push(...a); scheduleScan(); };
history.replaceState = (...a) => { _replace(...a); scheduleScan(); };
window.addEventListener('popstate', scheduleScan);
Instagram never does a full page reload — it is a React single-page application. Every navigation (clicking a post, opening a Reel, tapping a Story) changes the URL via history.pushState and then swaps in new DOM nodes. A userscript that only ran scan() at page load would inject buttons on the first page, then miss everything after the first navigation.
The watcher uses two complementary strategies:
MutationObserver— fires whenever new DOM nodes are added anywhere under<body>. This catches Instagram’s lazy-loading of feed posts as you scroll, and also catches the DOM swap that happens on navigation.pushState/replaceStateintercepts — wrap the native history API methods so thatscheduleScan()is called whenever Instagram programmatically navigates. This is more reliable thanMutationObserveralone for catching navigation that starts with a URL change before new DOM has been inserted.
The BTN_CLASS scroll-hang fix. Every wrapper.appendChild(btn) call would previously re-trigger the observer (it is a child insertion, which is exactly what childList: true watches for). This reset the debounce timer continuously during the initial scan, and every new button added during infinite-scroll caused yet another scan to be scheduled. The result was near-constant work that competed with Instagram’s scroll animation. The fix is to give every button we create a CSS class (ig-dl-btn) and then check, in the observer callback, that at least one of the added nodes does not carry that class — meaning it was added by Instagram, not by us — before scheduling a scan. Since the guard checks nodeType === 1 first (ensuring an element node), classList is guaranteed to exist and can be accessed without a null check.
requestIdleCallback. scheduleScan switches from setTimeout(scan, 600) to requestIdleCallback(scan, { timeout: 1000 }) when the browser supports it. requestIdleCallback defers execution to periods when the main thread is not busy — specifically, not during scroll frames. This prevents scan() from running at a moment when it would force a layout (from the getBoundingClientRect() calls inside) mid-scroll. The 1-second timeout ensures the callback is never delayed indefinitely.
The data-igDl Guard
if (videoEl.dataset.igDl) return;
videoEl.dataset.igDl = '1';
Every handler checks for a data-ig-dl attribute on the element before doing anything. Once a button has been attached, the attribute is set. The next time scan() runs — after a DOM mutation or navigation — the guard prevents a second button from being added to the same element.
Why No Download Button for Photos?
Instagram photos can be saved with a simple right-click → Save image as…. The script already restores that capability via installKillShieldsCSS:
GM_addStyle('img { pointer-events: auto !important; user-select: auto !important; position: relative !important; z-index: 999 !important; }');
A dedicated download button would need to reliably identify the correct post image among multiple <img> elements in each <article> — the profile avatar, the post image, and any carousel thumbnails. Getting this wrong is worse than having no button at all. Since right-click works reliably once the kill shields are in place, a button adds complexity without benefit.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No ⬇ button appears anywhere | Script is not active | Open the Tampermonkey dashboard and confirm the script is enabled and the @match line is correct |
| Button appears but clicking shows ❌ | React Fiber tree did not contain a URL (and captureStream failed for reels) |
Instagram may have updated their internal structure; try the individual console scripts from the linked posts |
| Right-click still blocked on images | GM_addStyle grant missing |
Re-install the script from scratch — Tampermonkey caches granted permissions and will not apply the new @grant GM_addStyle line unless you delete and reinstall |
| Button is greyed out after clicking | Download is in progress | Wait for ✅ or ❌ — the button re-enables automatically after ~2–3 seconds |
| Tampermonkey shows a domain-not-allowed error | @connect list doesn’t cover the CDN hostname |
Add @connect * to the header as a temporary catch-all while you identify the exact CDN domain from DevTools |
| Reel button shows ⏳ for a long time | MediaRecorder path: the full reel must play through before the file is ready | Let the video finish playing — duration depends on reel length |
| Script breaks after an Instagram update | Instagram periodically changes their React component structure | Check for an updated version of this script; the Fiber key prefix (__reactFiber$) is stable but the object layout inside can shift |
For the individual console-based versions of each technique, see Save Instagram Photos, Instagram Reel Sniper, and Instagram Story Sniper. You can also download Reddit videos and download YouTube Shorts with the same browser-only approach.
Disclaimer: This script is provided for educational purposes only. It demonstrates publicly documented browser APIs (
MutationObserver,history.pushState,captureStream,MediaRecorder) and React internals that are observable in any browser’s DevTools. Downloading content from Instagram may be against Instagram’s Terms of Service. Only download content you own, have explicit permission to save, or that is explicitly made available for download. Respect copyright and the work of content creators.