Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/config/database.js	(revision ba76bf58b02956cca979e2da1e7db24fc3eecea5)
@@ -499,4 +499,10 @@
     );
     CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
+    -- canonicalReactionUri herleidt een permalink naar het object-id door op (slug, url)
+    -- te zoeken. Zonder deze index viel dat terug op idx_ap_timeline_slug, dus een scan
+    -- van elke rij van die slug. Dat gebeurt PER REACTIE in getInteractions, en de
+    -- reactie-migratie erft het in haar re-key-join, die synchroon vóór listen draait:
+    -- de opstartkosten waren reacties maal tijdlijnrijen.
+    CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
     CREATE TABLE IF NOT EXISTS ap_blocks (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -850,7 +856,7 @@
     }
     // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
-    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '') => `
+    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
       DROP TRIGGER IF EXISTS ${naam};
-      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel} BEGIN
+      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
         UPDATE ap_feed_rev SET n = n + 1;
         INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
@@ -874,7 +880,16 @@
       // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
       // en vandaar dat deze drie niet in de gewone vorm passen.
-      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`),
-      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`),
-      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`),
+      //
+      // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
+      // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
+      // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
+      // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
+      // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
+      // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
+      // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
+      // (de UPDATE OF sluit liked/boosted uit) en één tabel verder vergeten.
+      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
+      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
+      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
     ].join('\n'));
   } catch (e) {
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveExportService.js	(revision ba76bf58b02956cca979e2da1e7db24fc3eecea5)
@@ -38,5 +38,19 @@
 
 const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-const toISO = (d) => { const t = Date.parse(d); return isNaN(t) ? null : new Date(t).toISOString(); };
+// SQLite's CURRENT_TIMESTAMP schrijft 'YYYY-MM-DD HH:MM:SS' in UTC, zonder marker.
+// Kale Date.parse leest dat als LOKALE tijd, dus op een server op UTC+2 ging er twee
+// uur van elke stempel af voordat hij het archief in ging. Die verschuiving wordt bij
+// het exporteren ingebakken en valt niet weg bij het importeren: exporteer je in
+// Amsterdam, dan is die post overal permanent twee uur te vroeg, en in zomer- en
+// wintertijd verschillend. Het raakte de stempels die de database zelf zet (concepten,
+// ingeplande posts, gearchiveerde antwoorden), niet die uit de editor, dus de schade
+// was stil en gedeeltelijk. EXPORT-FORMAT.md schreef altijd al "ISO 8601, UTC" voor.
+// Zelfde regel als isoStamp() in ActivityPubService en stampMs() in guardianship/offers.
+const SQL_STAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/;
+const toISO = (d) => {
+  const s = String(d == null ? '' : d);
+  const t = Date.parse(SQL_STAMP.test(s) ? `${s.replace(' ', 'T')}Z` : s);
+  return isNaN(t) ? null : new Date(t).toISOString();
+};
 
 const MIME_BY_EXT = {
Index: test/feed-state-triggers.test.js
===================================================================
--- test/feed-state-triggers.test.js	(revision ba76bf58b02956cca979e2da1e7db24fc3eecea5)
+++ test/feed-state-triggers.test.js	(revision ba76bf58b02956cca979e2da1e7db24fc3eecea5)
@@ -0,0 +1,76 @@
+// De feed-wekkers mogen alleen op echte inhoud afgaan.
+//
+// ap_interactions draagt naast replies ook likes en announces, en die schrijven een
+// LEGE object_uri. Zonder een WHEN op kind bumpte elke inkomende like de rev, werd
+// elke long-poll-wachter gewekt, en kreeg die de hele collectie opnieuw terwijl er
+// niets aan veranderd was: precies de kosten die de 304 moest wegnemen. Bovendien
+// belandde er een rij op de lege string in ap_feed_state, die feedChangesSince
+// vervolgens als sleutel uitdeelt.
+//
+// Bij ap_timeline is hier wel op gelet (de UPDATE OF sluit liked en boosted uit).
+// Deze test legt vast dat het bij ap_interactions ook zo blijft.
+import { test, before } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+
+const mod = await import('../src/config/database.js');
+const db = mod.default;
+
+before(() => {
+  const stil = console.log;
+  console.log = () => {};          // het schema-initialisatielogboek is hier ruis
+  try { mod.initializeDatabase(); } finally { console.log = stil; }
+
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'u1', 'u1@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s1', 'me', 'Mijn site', 'u1');
+  db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+    .run('p1', 's1', 't', 'u1', 'T', 'C', 'published');
+});
+
+const rev = () => db.prepare('SELECT n FROM ap_feed_rev').get().n;
+const sleutels = () => db.prepare('SELECT object_uri FROM ap_feed_state').all().map((r) => r.object_uri);
+const voegToe = (kind, uri) => db
+  .prepare('INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri) VALUES (?,?,?,?)')
+  .run(kind, 'p1', uri, `https://elders.example/${kind}`);
+
+test('een like wekt geen wachters', () => {
+  const voor = rev();
+  voegToe('like', '');
+  assert.equal(rev(), voor, 'een like verandert de collectie niet, dus hoeft niemand te worden gewekt');
+});
+
+test('een boost wekt geen wachters', () => {
+  const voor = rev();
+  voegToe('announce', '');
+  assert.equal(rev(), voor);
+});
+
+test('er staat nooit een rij op de lege sleutel', () => {
+  assert.ok(!sleutels().includes(''), 'een lege object_uri als sleutel wordt door feedChangesSince uitgedeeld');
+});
+
+test('een reply wekt de wachters wel', () => {
+  const voor = rev();
+  voegToe('reply', 'https://elders.example/note/9');
+  assert.equal(rev(), voor + 1);
+  assert.ok(sleutels().includes('https://elders.example/note/9'));
+});
+
+test('een verdwenen reply wekt de wachters ook', () => {
+  const voor = rev();
+  db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply'").run();
+  assert.equal(rev(), voor + 1, 'anders blijft een verwijderd antwoord bij de client staan');
+});
+
+test('de permalink-lookup van reacties gebruikt een index', () => {
+  // canonicalReactionUri zoekt op (slug, url). Viel dat terug op idx_ap_timeline_slug,
+  // dan was het een scan van elke rij van die slug, per reactie, en de reactie-migratie
+  // erfde dat in haar re-key-join die synchroon voor listen draait.
+  const plan = db.prepare('EXPLAIN QUERY PLAN SELECT id FROM ap_timeline WHERE slug=? AND url=?')
+    .all('x', 'y').map((r) => r.detail).join(' ');
+  assert.match(plan, /USING INDEX idx_ap_timeline_url/, `verwachtte de url-index, kreeg: ${plan}`);
+  assert.doesNotMatch(plan, /SCAN ap_timeline/);
+});
