Index: src/services/ThumbnailService.js
===================================================================
--- src/services/ThumbnailService.js	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
+++ src/services/ThumbnailService.js	(revision 201ec066b5b266989539a48a81d460335a03ab6d)
@@ -27,4 +27,26 @@
 
 let _seq = 0;
+
+// Limit concurrent ffmpeg spawns. A cold-cache, image-heavy page fires many thumbnail
+// requests at once; without a cap each spawns its own ffmpeg → CPU saturation makes the
+// WHOLE instance slow (the thundering herd). With the cap, excess requests wait briefly
+// for a slot → bounded CPU, the page still loads (images just appear progressively).
+const MAX_CONCURRENT = 3;
+let _active = 0;
+const _waiters = [];
+function acquireSlot() {
+  if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
+  return new Promise((resolve) => _waiters.push(resolve));
+}
+function releaseSlot() {
+  const next = _waiters.shift();
+  if (next) next();      // transfer the slot directly to the next waiter (_active unchanged)
+  else _active--;
+}
+async function runFfmpeg(args) {
+  await acquireSlot();
+  try { await execFileP(ffmpegPath, args, { timeout: 20000 }); }
+  finally { releaseSlot(); }
+}
 
 function mediaRoot() {
@@ -57,5 +79,5 @@
   const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
   try {
-    await execFileP(ffmpegPath, [
+    await runFfmpeg([
       '-hide_banner', '-loglevel', 'error', '-y',
       '-i', orig,
@@ -68,5 +90,5 @@
       '-f', 'webp',
       tmp,
-    ], { timeout: 20000 });
+    ]);
     await fs.promises.rename(tmp, cached);
     return cached;
@@ -143,5 +165,5 @@
   try {
     await fs.promises.writeFile(tmpIn, buf);
-    await execFileP(ffmpegPath, [
+    await runFfmpeg([
       '-hide_banner', '-loglevel', 'error', '-y',
       '-i', tmpIn,
@@ -150,5 +172,5 @@
       '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
       tmpOut,
-    ], { timeout: 20000 });
+    ]);
     await fs.promises.rename(tmpOut, cached);
     return cached;
