Skip to content

Instantly share code, notes, and snippets.

@birkin
Last active October 15, 2025 13:52
Show Gist options
  • Select an option

  • Save birkin/5e0dcaf38695f9aea7492b0734ddc1ab to your computer and use it in GitHub Desktop.

Select an option

Save birkin/5e0dcaf38695f9aea7492b0734ddc1ab to your computer and use it in GitHub Desktop.
javascript showing two approaches to loading lots of images -- but only a few at a time
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thumbnail Loader — Ordered Queue & Viewer-Aware Queue</title>
<style>
:root { --gap: 10px; --bg: #0b1020; --card: #11172a; --ink: #e8ecff; --muted: #a6b0d9; }
html, body { margin:0; padding:0; background: var(--bg); color: var(--ink); font: 15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
header { position: sticky; top: 0; z-index: 5; background: linear-gradient(180deg, rgba(11,16,32,.96), rgba(11,16,32,.8)); border-bottom: 1px solid #223; }
header .wrap { max-width: 1100px; margin: 0 auto; padding: 12px 16px; display:grid; gap:10px; }
h1 { margin:0; font-size: 20px; }
.muted { color: var(--muted); }
.row { display:flex; gap:10px; flex-wrap: wrap; align-items:center; }
input, button { background: var(--card); color: var(--ink); border: 1px solid #2a3558; border-radius: 10px; padding: 8px 10px; }
button { cursor: pointer; font-weight: 600; }
main { max-width: 1100px; margin: 0 auto; padding: 16px; display:grid; gap: 16px; }
.card { background: var(--card); border:1px solid #2a3558; border-radius:14px; padding: 12px; display:grid; gap:10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: var(--gap); }
.thumb { position:relative; width: 96px; height: 96px; border-radius: 10px; overflow: hidden; background: #0e1326; border: 1px solid #2a3558; display: grid; place-items: center; color: var(--muted); font-size: 12px; }
.thumb img { display:block; width: 100%; height: 100%; object-fit: cover; }
.status { position:absolute; inset:auto 6px 6px 6px; background: rgba(0,0,0,.55); border:1px solid #2a3558; border-radius:8px; padding:2px 6px; font-size:11px; text-align:center; }
.pills { display:flex; gap:8px; }
.pill { font-size:12px; padding:4px 8px; border-radius:999px; border:1px solid #2a3558; color: var(--muted); }
.warn { color:#ff9; }
.sep { height:1px; background:#243; margin:4px 0; }
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>Thumbnail Loader — Ordered Queue & Viewer-Aware Queue</h1>
<div class="muted">All thumbnails start with a <b>“not loaded”</b> indicator. Use <b>Start Loading</b> on either approach. <b>Reset</b> clears everything and server counters.</div>
<div class="row">
<label>Count <input id="count" type="number" value="300" min="1" max="5000" /></label>
<label>Server concurrency limit <input id="serverLimit" type="number" value="24" min="1" max="256" /></label>
<button id="resetAll">Reset</button>
<div class="pills">
<span class="pill" id="inflight">in‑flight: 0</span>
<span class="pill" id="rejected">rejected: 0</span>
</div>
</div>
</div>
</header>
<main>
<section class="card" id="orderedCard">
<h2>Option 1 — Ordered queue (no burst): start loading and continue as slots open</h2>
<div class="muted">Strict item order with a client‑side concurrency cap. No initial “first X” burst; just steady flow.</div>
<div class="row">
<button id="startOrdered">Start Loading</button>
<label>Client concurrency <input id="clientLimitA" type="number" value="8" min="1" max="64" /></label>
<span id="orderedInfo" class="muted"></span>
</div>
<div id="gridOrdered" class="grid" aria-live="polite"></div>
</section>
<section class="card" id="viewerCard">
<h2>Option 2 — Viewer‑aware queue (IntersectionObserver + concurrency cap)</h2>
<div class="muted">Only loads items near/in the viewport; aborts off‑screen loads; caps in‑flight requests.</div>
<div class="row">
<button id="startViewer">Start Loading</button>
<label>Client concurrency <input id="clientLimitB" type="number" value="8" min="1" max="64" /></label>
<span id="viewerInfo" class="muted"></span>
</div>
<div id="gridViewer" class="grid" aria-live="polite"></div>
</section>
</main>
<script>
// --- Simulated server (no network required) ------------------------------
let SERVER_MAX_CONCURRENCY = 24; // configurable in UI
let serverInFlight = 0; let serverRejected = 0;
const updateServerPills = () => {
document.getElementById('inflight').textContent = `in‑flight: ${serverInFlight}`;
document.getElementById('rejected').textContent = `rejected: ${serverRejected}`;
};
updateServerPills();
function simulateServerFetch(id, signal) {
return new Promise((resolve, reject) => {
if (serverInFlight >= SERVER_MAX_CONCURRENCY) {
serverRejected++; updateServerPills();
return reject(new Error('429 too many concurrent requests'));
}
serverInFlight++; updateServerPills();
const t = 30 + Math.random() * 180; // 30-210ms
const timer = setTimeout(() => {
// generate a tiny PNG blob labeled with the id
const c = document.createElement('canvas'); c.width = 96; c.height = 96;
const g = c.getContext('2d');
g.fillStyle = `hsl(${(id*23)%360} 60% 18%)`; g.fillRect(0,0,96,96);
g.fillStyle = 'white'; g.font = '700 14px ui-monospace, monospace';
g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(String(id), 48, 50);
c.toBlob((blob) => { serverInFlight--; updateServerPills(); resolve(blob); }, 'image/png');
}, t);
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); serverInFlight--; updateServerPills(); reject(new DOMException('Aborted','AbortError')); }, { once:true });
});
}
// --- Shared utilities ----------------------------------------------------
const makeIds = (n) => Array.from({length:n}, (_, i) => i + 1);
const blobUrl = (blob) => URL.createObjectURL(blob);
const el = (tag, props={}) => { const e = document.createElement(tag); Object.assign(e, props); return e; };
function placeholderThumb(id) {
const wrap = el('div', { className:'thumb', id:`wrap-${id}` });
const status = el('div', { className:'status', textContent:'not loaded' });
wrap.appendChild(status);
return wrap;
}
function markLoaded(id, blob) {
const wrap = document.getElementById(`wrap-${id}`);
if (!wrap) return;
wrap.innerHTML = '';
const img = new Image();
img.alt = `thumb ${id}`; img.decoding = 'async';
img.src = blobUrl(blob);
wrap.appendChild(img);
}
function markFailed(id) {
const wrap = document.getElementById(`wrap-${id}`);
if (!wrap) return;
wrap.innerHTML = '';
const status = el('div', { className:'status', textContent:'failed' });
wrap.appendChild(status);
}
class Semaphore {
constructor(max) { this.max = max; this.active = 0; this.queue = []; }
run(task) { return new Promise((res, rej) => { const job = () => { this.active++; Promise.resolve(task()).then(res, rej).finally(()=>{ this.active--; this._next(); }); }; (this.active < this.max) ? job() : this.queue.push(job); }); }
_next(){ if(this.queue.length && this.active < this.max) this.queue.shift()(); }
}
// --- Option 1: Ordered queue (no burst) ---------------------------------
function buildOrderedGrid(count) {
const grid = document.getElementById('gridOrdered'); grid.innerHTML = '';
for (const id of makeIds(count)) grid.appendChild(placeholderThumb(id));
}
async function runOrdered() {
const count = parseInt(document.getElementById('count').value,10) || 300;
const clientLimit = parseInt(document.getElementById('clientLimitA').value,10) || 8;
const info = document.getElementById('orderedInfo'); info.textContent = '';
buildOrderedGrid(count);
const ids = makeIds(count);
const sem = new Semaphore(clientLimit);
let loaded = 0;
const startTask = (id) => sem.run(async () => {
try { const blob = await simulateServerFetch(id); markLoaded(id, blob); }
catch(_) { markFailed(id); }
finally { loaded++; info.textContent = `${loaded}/${count} loaded`; }
});
// Enqueue all items in strict order; they'll start as slots become available
for (const id of ids) startTask(id);
}
// --- Option 2: Viewer-aware queue ---------------------------------------
let viewerIO = null; let viewerControllers = new Map(); let viewerSem = null;
function buildViewerGrid(count) {
const grid = document.getElementById('gridViewer'); grid.innerHTML = '';
for (const id of makeIds(count)) grid.appendChild(placeholderThumb(id));
}
function startViewer() {
const count = parseInt(document.getElementById('count').value,10) || 300;
const clientLimit = parseInt(document.getElementById('clientLimitB').value,10) || 8;
const info = document.getElementById('viewerInfo'); info.textContent = '';
buildViewerGrid(count);
viewerControllers.forEach(c => c.abort()); viewerControllers.clear();
if (viewerIO) viewerIO.disconnect();
viewerSem = new Semaphore(clientLimit);
let loaded = 0;
viewerIO = new IntersectionObserver((entries) => {
for (const e of entries) {
const wrap = e.target; // .thumb
const id = parseInt(wrap.id.replace('wrap-',''), 10);
if (Number.isNaN(id)) continue;
if (e.isIntersecting) {
if (viewerControllers.has(id)) continue;
const ctrl = new AbortController(); viewerControllers.set(id, ctrl);
viewerSem.run(async () => {
try { const blob = await simulateServerFetch(id, ctrl.signal); markLoaded(id, blob); }
catch(_) { /* if aborted, ignore; else failed */ }
finally { viewerControllers.delete(id); loaded++; info.textContent = `${loaded}/${count} loaded`; }
});
} else {
const ctrl = viewerControllers.get(id); if (ctrl) { ctrl.abort(); viewerControllers.delete(id); }
}
}
}, { root: null, rootMargin: '400px 0px', threshold: 0.01 });
// Observe placeholders (the .thumb wrappers)
document.querySelectorAll('#gridViewer .thumb').forEach(wrap => viewerIO.observe(wrap));
}
// --- Wire up top controls ------------------------------------------------
document.getElementById('serverLimit').addEventListener('change', (e) => {
SERVER_MAX_CONCURRENCY = parseInt(e.target.value,10) || 24; updateServerPills();
});
document.getElementById('startOrdered').addEventListener('click', runOrdered);
document.getElementById('startViewer').addEventListener('click', startViewer);
document.getElementById('resetAll').addEventListener('click', () => {
// clear grids and info
['gridOrdered','gridViewer'].forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML=''; });
['orderedInfo','viewerInfo'].forEach(id => { const el = document.getElementById(id); if (el) el.textContent=''; });
// reset server counters
serverInFlight = 0; serverRejected = 0; updateServerPills();
// disconnect viewer observer and abort any inflight
if (viewerIO) viewerIO.disconnect();
viewerControllers.forEach(c => c.abort()); viewerControllers.clear();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment