Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/config/database.js	(revision 4ae254a5fe167b4e8ad9f22bbd40480efde820f1)
@@ -888,4 +888,23 @@
     );
     CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
+  `);
+  // De bezorgwachtrij per SERVER en per INBOX (shaer-xzab.3). `host` zodat één
+  // mislukking naar een server al zijn rijen opschuift, in plaats van dertig
+  // rijen met elk acht seconden time-out op dezelfde dode server; de index op
+  // (inbox, id) zodat "oudste eerst per inbox" geen volle scan per rij is.
+  // Rijen van voor deze kolom krijgen hun host hier, eenmalig.
+  ensureColumn('ap_delivery', 'host', 'TEXT');
+  try {
+    const zonder = db.prepare('SELECT id, inbox FROM ap_delivery WHERE host IS NULL').all();
+    const zet = db.prepare('UPDATE ap_delivery SET host = ? WHERE id = ?');
+    for (const r of zonder) {
+      let h = null;
+      try { h = new URL(r.inbox).host.toLowerCase(); } catch { /* onleesbaar: blijft null */ }
+      zet.run(h, r.id);
+    }
+  } catch { /* een lege of ontbrekende wachtrij is geen fout */ }
+  db.exec(`
+    CREATE INDEX IF NOT EXISTS idx_ap_delivery_inbox ON ap_delivery(inbox, id);
+    CREATE INDEX IF NOT EXISTS idx_ap_delivery_host ON ap_delivery(host);
   `);
   // "Feature" a followed account: its posts show in the local Cirkel. Heet in de
Index: src/services/ap-transport.js
===================================================================
--- src/services/ap-transport.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/services/ap-transport.js	(revision 4ae254a5fe167b4e8ad9f22bbd40480efde820f1)
@@ -193,19 +193,74 @@
 // doesn't silently miss the post. The signing key is NOT stored — the worker
 // re-derives it from the actor slug at send time.
-const DELIVERY_MAX_ATTEMPTS = 6;
-const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
-let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
+//
+// KLONKT DIE NIET ALTIJD AAN STAAT (shaer-xzab). Een Klonkt op een Mac thuis
+// slaapt, gaat de trein in, staat een weekend uit. Daarom:
+//
+//  - Een bezorging VERLOOPT OP LEEFTIJD (5 dagen sinds created_at), niet na een
+//    aantal pogingen (xzab.1). Zes pogingen waren ~4,5 uur, en de laatste trede
+//    van 360 minuten werd nooit gebruikt: de telling was er eerder.
+//  - OFFLINE ZIJN KOST GEEN POGINGEN (xzab.2). Mislukt een hele beurt op
+//    netwerkniveau, naar meer dan één server en zonder één HTTP-antwoord, dan
+//    ligt het aan ons: alles een paar minuten opschuiven, de telling ongemoeid,
+//    en niets laten verlopen. Slapen kost al niets, want dan loopt de timer niet.
+//  - BACKOFF PER SERVER, VOLGORDE PER INBOX (xzab.3). Eén mislukking naar een
+//    server schuift al zijn rijen op. En per inbox oudste eerst, ook bij het
+//    directe versturen: bij een venster van dagen mag een herhaalde Create niet
+//    NA de Delete van dezelfde post aankomen.
+const DELIVERY_MAX_AGE_MS = 5 * 24 * 60 * 60 * 1000;
+// Minuten tot de volgende poging, op de poging die net mislukte; de laatste
+// trede herhaalt tot de bezorging verloopt.
+const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360, 720, 1440];
+// Hoe ver een beurt opschuift als WIJ offline blijken.
+const OFFLINE_RETRY_MIN = 2;
+
+export function deliveryBackoffMinutes(attemptsSoFar) {
+  return DELIVERY_BACKOFF_MIN[Math.min(Math.max(0, attemptsSoFar), DELIVERY_BACKOFF_MIN.length - 1)];
+}
+
+/** De host (met poort) van een inbox, in kleine letters; null als het geen URL is. */
+export function inboxHost(inbox) {
+  try { return new URL(inbox).host.toLowerCase(); } catch { return null; }
+}
+
+/** Is een bezorging zo oud dat hij opgegeven wordt? created_at is SQLite-UTC
+ *  ('YYYY-MM-DD HH:MM:SS', zonder zone). */
+export function deliveryExpired(createdAt, now = Date.now()) {
+  if (!createdAt) return false;
+  const t = Date.parse(String(createdAt).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(createdAt) ? '' : 'Z'));
+  return Number.isFinite(t) && now - t >= DELIVERY_MAX_AGE_MS;
+}
+
+/** Faalde dit op NETWERKNIVEAU (geen HTTP-antwoord)? Een SSRF-weigering is
+ *  dat niet: die is een besluit, en blijft een besluit als we weer online zijn. */
+function isNetworkError(e) {
+  return !/^ssrf/.test(String((e && e.message) || ''));
+}
+
+let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv, _hostDeliv, _offDeliv, _pendInbox, _hostWaits;
 function deliveryStmts() {
   if (!_insDeliv) {
-    _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
-    _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
+    _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, host, body, attempts, next_at) VALUES (?,?,?,?,0,CURRENT_TIMESTAMP)');
+    // Alleen de OUDSTE openstaande rij per inbox: een nieuwere wacht op hem, ook
+    // als hij zelf al aan de beurt zou zijn. Op id, want dat is de volgorde
+    // waarin ze ontstonden.
+    _dueDeliv = db.prepare(`SELECT d.* FROM ap_delivery d
+      WHERE datetime(d.next_at) <= datetime('now')
+        AND NOT EXISTS (SELECT 1 FROM ap_delivery o WHERE o.inbox = d.inbox AND o.id < d.id)
+      ORDER BY d.id LIMIT 30`);
     _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
     _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
-  }
-  return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
+    // Een server die faalde: al zijn rijen niet eerder dan de nieuwe poging.
+    _hostDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE host = ? AND datetime(next_at) < datetime(?)');
+    // Wij offline: opschuiven, de telling ongemoeid.
+    _offDeliv = db.prepare('UPDATE ap_delivery SET next_at = ? WHERE id = ?');
+    _pendInbox = db.prepare('SELECT 1 FROM ap_delivery WHERE inbox = ? LIMIT 1');
+    _hostWaits = db.prepare("SELECT 1 FROM ap_delivery WHERE host = ? AND datetime(next_at) > datetime('now') LIMIT 1");
+  }
+  return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv, host: _hostDeliv, off: _offDeliv, pendInbox: _pendInbox, hostWaits: _hostWaits };
 }
 export function enqueueDelivery(slug, inbox, activity) {
   if (!slug || !inbox || !activity) return;
-  try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
+  try { deliveryStmts().ins.run(slug, inbox, inboxHost(inbox), JSON.stringify(activity)); } catch { /* ignore */ }
 }
 // Record delivery health per follower so the followers list can flag dead accounts.
@@ -224,6 +279,15 @@
 }
 // Deliver now; queue for retry if it fails.
+//
+// Niet meteen als er voor deze inbox al iets in de wachtrij staat (dan zou dit
+// er voorbij gaan: een Delete vóór de Create die nog wacht), en niet als deze
+// server net faalde en in zijn backoff zit (dan kost het alleen acht seconden
+// time-out om hetzelfde te horen). Dan achteraan in de rij.
 export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
   if (!inbox) return;
+  try {
+    const st = deliveryStmts();
+    if (st.pendInbox.get(inbox) || st.hostWaits.get(inboxHost(inbox))) { enqueueDelivery(slug, inbox, activity); return; }
+  } catch { /* geen wachtrij te lezen: gewoon proberen */ }
   try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
   enqueueDelivery(slug, inbox, activity);
@@ -238,18 +302,48 @@
     if (!rows || !rows.length) return;
     const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+    // Eerst alles proberen, pas daarna beslissen: of het aan ons lag, zie je
+    // pas aan de hele beurt.
+    const failed = [];          // { row, network }
+    const down = new Set();     // servers die in deze beurt al faalden
+    const skipped = [];         // rijen van zo'n server, niet meer geprobeerd
+    let answered = false;       // kwam er ergens een HTTP-antwoord?
     for (const row of rows) {
-      let ok = false;
+      const host = row.host || inboxHost(row.inbox);
+      if (host && down.has(host)) { skipped.push(row); continue; }
+      let ok = false, network = false;
       try {
         const keys = getOrCreateKeys(row.slug);
         const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
+        answered = true;
         ok = st >= 200 && st < 300;
-      } catch { ok = false; }
+      } catch (e) { network = isNetworkError(e); }
       if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
-      const attempts = row.attempts + 1;
-      if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
-      // Index the backoff on the CURRENT attempt count (row.attempts) so the first
-      // retry uses the 1-min tier instead of skipping it.
-      const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
-      deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
+      failed.push({ row, network, host });
+      if (host) down.add(host);
+    }
+    if (!failed.length) return;
+
+    // Lag het aan ons? Niets beantwoord, alles op netwerkniveau, en naar meer
+    // dan één server -- bij één server is "hij ligt eruit" net zo waarschijnlijk.
+    const hosts = new Set(failed.map((f) => f.host));
+    const offline = !answered && failed.every((f) => f.network) && hosts.size > 1;
+    if (offline) {
+      const later = new Date(Date.now() + OFFLINE_RETRY_MIN * 60000).toISOString();
+      for (const r of [...failed.map((f) => f.row), ...skipped]) deliveryStmts().off.run(later, r.id);
+      console.warn('[AP] delivery: no network for', failed.length, 'deliveries to', hosts.size, 'servers; retrying in', OFFLINE_RETRY_MIN, 'min, attempts untouched');
+      return;
+    }
+
+    for (const { row, host } of failed) {
+      if (deliveryExpired(row.created_at)) {
+        markFollowerDelivery(row.slug, row.inbox, false);
+        deliveryStmts().del.run(row.id);
+        console.warn('[AP] delivery gave up after', row.attempts + 1, 'tries over 5 days →', row.inbox);
+        continue;
+      }
+      const next = new Date(Date.now() + deliveryBackoffMinutes(row.attempts) * 60000).toISOString();
+      deliveryStmts().bump.run(row.attempts + 1, next, row.id);
+      // De hele server wacht mee: zijn andere rijen niet eerder dan deze.
+      if (host) deliveryStmts().host.run(next, host, next);
     }
   } finally { _processingDeliv = false; }
Index: test/delivery-queue.test.js
===================================================================
--- test/delivery-queue.test.js	(revision 4ae254a5fe167b4e8ad9f22bbd40480efde820f1)
+++ test/delivery-queue.test.js	(revision 4ae254a5fe167b4e8ad9f22bbd40480efde820f1)
@@ -0,0 +1,191 @@
+// De bezorgwachtrij voor een Klonkt die niet altijd aan staat (shaer-xzab).
+//
+// Wat hier bewaakt wordt, elk met zijn eigen toets:
+//   .1 een bezorging verloopt op LEEFTIJD (5 dagen), niet na zes pogingen, en
+//      de treden lopen door tot een dag;
+//   .2 offline zijn kost geen pogingen: een beurt die naar meer dan één server
+//      op netwerkniveau faalt, schuift op zonder te tellen en laat niets
+//      verlopen -- maar een SSRF-weigering of één dode server telt gewoon;
+//   .3 één mislukking naar een server schuift al zijn rijen op, en per inbox
+//      gaat de oudste eerst, ook bij het directe versturen.
+//
+// Tegen echte HTTP-servers op 127.0.0.1, toegelaten via AP_ALLOW_HOSTS, en twee
+// poorten waar niets luistert als "dode server".
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import http from 'http';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+// Twee inboxen die antwoorden, en wat ze kregen.
+const ontvangen = [];               // { host, type, id }
+const status = new Map();           // host -> HTTP-status (standaard 202)
+function inbox() {
+  const s = http.createServer((req, res) => {
+    let body = '';
+    req.on('data', (c) => { body += c; });
+    req.on('end', () => {
+      const host = req.headers.host;
+      try { const a = JSON.parse(body); ontvangen.push({ host, type: a.type, id: a.id }); } catch { /* geen json */ }
+      res.statusCode = status.get(host) || 202; res.end();
+    });
+  });
+  return new Promise((r) => s.listen(0, '127.0.0.1', () => r(s)));
+}
+async function dichtePoort() {
+  const s = http.createServer();
+  await new Promise((r) => s.listen(0, '127.0.0.1', r));
+  const p = s.address().port;
+  await new Promise((r) => s.close(r));
+  return p;
+}
+const A = await inbox();
+const B = await inbox();
+const hostA = `127.0.0.1:${A.address().port}`;
+const hostB = `127.0.0.1:${B.address().port}`;
+const dood1 = `127.0.0.1:${await dichtePoort()}`;
+const dood2 = `127.0.0.1:${await dichtePoort()}`;
+process.env.AP_ALLOW_HOSTS = [hostA, hostB, dood1, dood2].join(',');
+test.after(() => { A.close(); B.close(); });
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const T = await import('../src/services/ap-transport.js');
+const stilWarn = (f) => async () => { const w = console.warn; console.warn = () => {}; try { await f(); } finally { console.warn = w; } };
+
+const url = (host, pad = '/inbox') => `http://${host}${pad}`;
+const leeg = () => { db.prepare('DELETE FROM ap_delivery').run(); ontvangen.length = 0; status.clear(); };
+const rij = (id) => db.prepare('SELECT * FROM ap_delivery WHERE id = ?').get(id);
+const alle = () => db.prepare('SELECT * FROM ap_delivery ORDER BY id').all();
+function zet(inboxUrl, activity, { dagenOud = 0, attempts = 0, over = 0 } = {}) {
+  T.enqueueDelivery('kind', inboxUrl, activity);
+  const id = db.prepare('SELECT max(id) id FROM ap_delivery').get().id;
+  db.prepare(`UPDATE ap_delivery SET attempts = ?,
+      created_at = datetime('now', ?), next_at = datetime('now', ?) WHERE id = ?`)
+    .run(attempts, `-${dagenOud * 24 * 60} minutes`, `${over >= 0 ? '+' : ''}${over} minutes`, id);
+  return id;
+}
+const minutenTot = (iso) => (Date.parse(String(iso).replace(' ', 'T') + (/Z$/.test(iso) ? '' : 'Z')) - Date.now()) / 60000;
+
+// ── .1 Verlopen op leeftijd ──────────────────────────────────────
+
+test('de treden lopen door tot een dag, en de laatste herhaalt', () => {
+  assert.deepEqual([0, 1, 2, 3, 4, 5, 6, 7, 30].map(T.deliveryBackoffMinutes), [1, 5, 15, 60, 180, 360, 720, 1440, 1440]);
+});
+
+test('zes pogingen is geen reden meer om op te geven; vijf dagen wel', stilWarn(async () => {
+  leeg();
+  status.set(hostA, 500);
+  const jong = zet(url(hostA, '/jong'), { type: 'Create', id: 'n1' }, { dagenOud: 4, attempts: 6 });
+  await T.processDeliveryQueue();
+  assert.ok(rij(jong), 'vier dagen oud met zes pogingen: blijft staan');
+  assert.equal(rij(jong).attempts, 7);
+  assert.ok(Math.abs(minutenTot(rij(jong).next_at) - 720) < 2, 'en de trede van twaalf uur wordt gebruikt');
+
+  leeg();
+  status.set(hostA, 500);
+  const oud = zet(url(hostA, '/oud'), { type: 'Create', id: 'n2' }, { dagenOud: 6, attempts: 2 });
+  await T.processDeliveryQueue();
+  assert.equal(rij(oud), undefined, 'zes dagen oud: opgegeven');
+}));
+
+test('de leeftijd leest SQLite-UTC zoals hij is', () => {
+  const nu = Date.parse('2026-10-26T12:00:00Z');
+  assert.equal(T.deliveryExpired('2026-10-21 12:00:01', nu), false);
+  assert.equal(T.deliveryExpired('2026-10-21 12:00:00', nu), true);
+});
+
+// ── .2 Offline kost niets ────────────────────────────────────────
+
+test('geen netwerk naar twee servers: opschuiven, niet tellen, niet verlopen', stilWarn(async () => {
+  leeg();
+  const een = zet(url(dood1), { type: 'Create', id: 'n3' }, { attempts: 3, dagenOud: 6 });
+  const twee = zet(url(dood2), { type: 'Create', id: 'n4' }, { attempts: 1 });
+  await T.processDeliveryQueue();
+  assert.ok(rij(een) && rij(twee), 'niets opgegeven, ook niet wat oud was');
+  assert.equal(rij(een).attempts, 3);
+  assert.equal(rij(twee).attempts, 1);
+  assert.ok(Math.abs(minutenTot(rij(een).next_at) - 2) < 1, 'een paar minuten opgeschoven');
+}));
+
+test('één dode server is gewoon een dode server, en telt', stilWarn(async () => {
+  leeg();
+  const id = zet(url(dood1), { type: 'Create', id: 'n5' }, { attempts: 2 });
+  await T.processDeliveryQueue();
+  assert.equal(rij(id).attempts, 3);
+}));
+
+test('een SSRF-weigering is een besluit, geen offline', stilWarn(async () => {
+  leeg();
+  const een = zet('http://127.0.0.2:9/inbox', { type: 'Create', id: 'n6' });
+  const twee = zet('http://127.0.0.3:9/inbox', { type: 'Create', id: 'n7' });
+  await T.processDeliveryQueue();
+  assert.equal(rij(een).attempts, 1, 'geteld, ook al antwoordde niemand');
+  assert.equal(rij(twee).attempts, 1);
+}));
+
+// ── .3 Per server en per inbox ───────────────────────────────────
+
+test('één mislukking naar een server schuift al zijn rijen op', stilWarn(async () => {
+  leeg();
+  status.set(hostA, 503);
+  const ids = ['/x', '/y', '/z'].map((p, i) => zet(url(hostA, p), { type: 'Create', id: `h${i}` }));
+  const elders = zet(url(hostB), { type: 'Create', id: 'b1' });
+  await T.processDeliveryQueue();
+  assert.equal(ontvangen.filter((o) => o.host === hostA).length, 1, 'één verzoek naar de dode server, niet drie');
+  const eerste = rij(ids[0]);
+  for (const id of ids) assert.ok(minutenTot(rij(id).next_at) >= minutenTot(eerste.next_at) - 0.1, 'allemaal niet eerder dan de mislukte');
+  assert.deepEqual(ids.slice(1).map((id) => rij(id).attempts), [0, 0], 'wat niet geprobeerd is, telt niet');
+  assert.equal(rij(elders), undefined, 'een andere server gaat gewoon door');
+}));
+
+test('per inbox de oudste eerst: geen Delete voor de Create die nog wacht', async () => {
+  leeg();
+  const create = zet(url(hostA), { type: 'Create', id: 'p1' }, { over: 30 });   // wacht nog
+  zet(url(hostA), { type: 'Delete', id: 'p1-del' });                              // al aan de beurt
+  await T.processDeliveryQueue();
+  assert.deepEqual(ontvangen, [], 'de Delete wacht op de Create');
+  db.prepare("UPDATE ap_delivery SET next_at = datetime('now', '-1 minutes') WHERE id = ?").run(create);
+  await T.processDeliveryQueue();
+  await T.processDeliveryQueue();
+  assert.deepEqual(ontvangen.map((o) => o.type), ['Create', 'Delete']);
+  assert.deepEqual(alle(), []);
+});
+
+test('direct versturen gaat niet voorbij wat er voor die inbox wacht', async () => {
+  leeg();
+  const { private_pem } = T.getOrCreateKeys('kind');
+  // De Create is AL aan de beurt, alleen nog niet opgepakt: de server zit dus
+  // niet in een backoff, en alleen de volgorde per inbox houdt de Delete tegen.
+  zet(url(hostA), { type: 'Create', id: 'q1' }, { over: -1 });
+  await T.deliverWithRetry('kind', url(hostA), { type: 'Delete', id: 'q1-del' }, 'https://test.example/ap/users/kind#main-key', private_pem);
+  assert.deepEqual(ontvangen, [], 'niet verstuurd');
+  assert.deepEqual(alle().map((r) => JSON.parse(r.body).type), ['Create', 'Delete'], 'maar achteraan in de rij');
+});
+
+test('en niet naar een server die in zijn backoff zit', async () => {
+  leeg();
+  const { private_pem } = T.getOrCreateKeys('kind');
+  zet(url(hostA, '/ander'), { type: 'Create', id: 'r1' }, { over: 30 });
+  await T.deliverWithRetry('kind', url(hostA, '/nog-een'), { type: 'Create', id: 'r2' }, 'https://test.example/ap/users/kind#main-key', private_pem);
+  assert.deepEqual(ontvangen, [], 'geen acht seconden time-out om hetzelfde te horen');
+  assert.equal(alle().length, 2);
+});
+
+test('zonder wachtrij gaat direct versturen gewoon direct', async () => {
+  leeg();
+  const { private_pem } = T.getOrCreateKeys('kind');
+  await T.deliverWithRetry('kind', url(hostB), { type: 'Create', id: 's1' }, 'https://test.example/ap/users/kind#main-key', private_pem);
+  assert.deepEqual(ontvangen.map((o) => o.id), ['s1']);
+  assert.deepEqual(alle(), []);
+});
+
+test('rijen van voor de host-kolom krijgen hun host bij het opstarten', () => {
+  leeg();
+  db.prepare("INSERT INTO ap_delivery (slug, inbox, body) VALUES ('kind', ?, '{}')").run(url(hostB, '/oud'));
+  db.prepare('UPDATE ap_delivery SET host = NULL').run();
+  { const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+  assert.equal(alle()[0].host, hostB);
+});
