How to Load Lazy-Load Images in Background
I have an assumption about average internet speed — it’s fast enough for most parts of the world to have streaming services. But somehow, some websites still do use lazy loaders on their images and assets. Mad Tea Party is one of them, due to server’s resource issues. But for websites that have larger traffic, for the life of me, I don’t understand why they would want to have users wait for each image to load in-between the reading session.
It’s a userscript I have for today. It’s a product of vibe-coding. Claude did the good job of commenting all the places where it is necessary. My part in this script is mostly about how it should behave, not how to ‘solve’ the lazy loader itself.
// ==UserScript==
// @name Lazy Load Solver (Background)
// @namespace https://themtparty.com
// @version 2.0.0
// @description Forces all lazy-loaded images to load immediately without moving the viewport.
// Intercepts IntersectionObserver, swaps data-src attributes, and fires synthetic
// scroll events — all invisibly in the background.
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ── 1. Intercept IntersectionObserver (catches most modern lazy loaders) ──
//
// Wraps the native constructor so that every time a site calls
// observer.observe(element), we immediately fire the callback with
// isIntersecting: true — tricking the loader into thinking the element
// is already on screen.
//
const NativeIO = window.IntersectionObserver;
window.IntersectionObserver = function (callback, options) {
const native = new NativeIO(callback, options);
const nativeObserve = native.observe.bind(native);
native.observe = function (target) {
// Fire immediately with a fake "fully visible" entry
try {
const rect = typeof target.getBoundingClientRect === 'function'
? target.getBoundingClientRect()
: new DOMRectReadOnly(0, 0, 0, 0);
callback([{
isIntersecting: true,
intersectionRatio: 1,
boundingClientRect: rect,
intersectionRect: rect,
rootBounds: null,
target: target,
time: performance.now(),
}], native);
} catch (_) { /* target may not be in DOM yet; native observe will still work */ }
nativeObserve(target);
};
return native;
};
// Copy static properties so feature-detection on the constructor doesn't break
Object.assign(window.IntersectionObserver, NativeIO);
window.IntersectionObserver.prototype = NativeIO.prototype;
// ── 2. data-src sweeper (catches older / jQuery-based lazy loaders) ───────
//
// Common attribute names used by lazy loader libraries.
// Runs once after DOMContentLoaded, then watches for dynamically added nodes.
//
const SRC_ATTRS = [
'data-src', 'data-lazy', 'data-lazy-src', 'data-original',
'data-url', 'data-hi-res', 'data-srcset', 'data-lazy-srcset',
];
function sweepElement(el) {
for (const attr of SRC_ATTRS) {
const val = el.getAttribute(attr);
if (!val) continue;
if (attr.includes('srcset')) {
if (!el.srcset) el.srcset = val;
} else if (el.tagName === 'IMG' && !el.src) {
el.src = val;
} else if (el.tagName === 'SOURCE' && !el.srcset) {
el.srcset = val;
} else if (el.tagName !== 'IMG' && el.tagName !== 'SOURCE') {
// background-image lazy loading on a div/section
el.style.backgroundImage = `url('${val}')`;
}
}
// Remove native browser lazy loading so it fetches immediately
if (el.tagName === 'IMG' && el.getAttribute('loading') === 'lazy') {
el.setAttribute('loading', 'eager');
}
}
function sweepAll() {
const selector = SRC_ATTRS.map(a => `[${a}]`).join(',') + ', img[loading="lazy"]';
document.querySelectorAll(selector).forEach(sweepElement);
}
// ── 3. Synthetic scroll burst (catches scroll-event-based loaders) ────────
//
// Fires scroll events on window without changing scrollY.
// Scroll-based loaders re-check element visibility on each scroll event,
// so this gives them a nudge without moving anything.
//
function syntheticScrollBurst() {
let ticks = 0;
const id = setInterval(() => {
window.dispatchEvent(new Event('scroll', { bubbles: true }));
document.dispatchEvent(new Event('scroll', { bubbles: true }));
if (++ticks >= 10) clearInterval(id);
}, 50);
}
// ── 4. MutationObserver — handle dynamically injected content ─────────────
//
// Sites with infinite scroll or AJAX pagination inject new nodes after load.
// Sweeping added nodes ensures late-arriving images are caught too.
//
function watchDOM() {
const mo = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
sweepElement(node);
node.querySelectorAll?.(
SRC_ATTRS.map(a => `[${a}]`).join(',') + ', img[loading="lazy"]'
).forEach(sweepElement);
}
}
});
mo.observe(document.body, { childList: true, subtree: true });
}
// ── Orchestration ─────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
function init() {
sweepAll(); // handle elements already in the DOM
syntheticScrollBurst(); // trigger scroll-event-based loaders
watchDOM(); // catch future dynamically added content
}
})();
A tangent. Claude Pro subscription, after writing few more userscripts like this, has used up weekly usage (i.e. tokens). Userscript is definitely an area I find pleasure tweaking around with the assistance from an AI; after all, JavaScript gnaws at soul every time I have to work with it. In a way, I feel like the LLM is paying itself off. It’s like a data plan for your phone. If you are paying for 5 GB plan, it’s either use it or lose it every month. I am literally draining Claude every week; the only downside so far was it lacks ‘basic mode’ for me to fall back to, like Gemini does.

Comments will be automatically closed after 30 days.