Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 1d76e0e533a2fe46c00fba8193b72c7be6685346)
+++ src/routes/activitypub.js	(revision bfd9c7325cd36c5b855d62c6a23001f6733ef4f8)
@@ -321,5 +321,8 @@
     const base = baseUrl(req);
     const me = `${base}/ap/users/${auth.site.slug}`;
-    AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
+    // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
+    // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
+    // honderd wards inclusief poorten terug.
+    AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
   });
 }
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 1d76e0e533a2fe46c00fba8193b72c7be6685346)
+++ src/routes/guardian.js	(revision bfd9c7325cd36c5b855d62c6a23001f6733ef4f8)
@@ -185,5 +185,7 @@
   const site = siteForUser(req);
   if (!site) return res.status(404).json({ error: 'no_site' });
-  res.json(dashboardState(site, resolveLang(req)));
+  // Het paneel tikt elke 45 seconden, of er nu iets gebeurd is of niet. Met een
+  // ETag kost stilte een lege 304 in plaats van het hele paneel (Barts punt, 9-8).
+  return AP.sendMaybe304(req, res, dashboardState(site, resolveLang(req)), { contentType: 'application/json' });
 });
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 1d76e0e533a2fe46c00fba8193b72c7be6685346)
+++ src/services/ActivityPubService.js	(revision bfd9c7325cd36c5b855d62c6a23001f6733ef4f8)
@@ -211,4 +211,44 @@
 
 const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
+/**
+ * Hetzelfde antwoord als de vorige keer? Dan 304 (Barts punt, 9-8).
+ *
+ * De inbox doet dit al met `since` + `wait`, en de guardian-wachtrijen niet: die
+ * stuurden bij elke verversing de hele lijst terug, ook als er niets veranderd
+ * was. Bij honderd wards is dat 217 KB JSON die de telefoon opnieuw moet
+ * parsen -- over de lijn valt het mee (2,8 KB gzip), maar het OPBOUWEN van
+ * veertienhonderd objecten is wat je merkt.
+ *
+ * EEN INHOUDS-ETAG, geen cursor. Een cursor vraagt een tweede beschrijving van
+ * wanneer iets "veranderd" is, en die kan uit de pas gaan lopen met wat er
+ * werkelijk in het antwoord staat; een hash van het antwoord zelf kan dat per
+ * definitie niet. De server bouwt het antwoord nog steeds (26 ms) -- wat we
+ * besparen is de overdracht en het parsen.
+ *
+ * NOOIT 304 OP EEN LEEG ANTWOORD. Dezelfde les als de '0'-uitzondering bij de
+ * inbox: gaat er bij het opbouwen iets mis en komt er een lege lijst uit, dan is
+ * die hash ook stabiel, en zou een client voor eeuwig 304 krijgen op niets.
+ */
+export function etagFor(body) {
+  return `"${crypto.createHash('sha256').update(body).digest('base64url').slice(0, 27)}"`;
+}
+
+export function sendMaybe304(req, res, obj, { cacheControl, contentType } = {}) {
+  const body = JSON.stringify(obj);
+  const leeg = !obj || (Array.isArray(obj.orderedItems) && obj.orderedItems.length === 0);
+  res.set('Vary', 'Authorization');
+  if (!leeg) {
+    const tag = etagFor(body);
+    res.set('ETag', tag);
+    if (req.headers['if-none-match'] === tag) return res.status(304).end();
+  }
+  res.type(contentType || AP_CONTENT_TYPE);
+  // `no-cache` betekent NIET "niet bewaren": de client bewaart het antwoord en
+  // vraagt elke keer of het nog klopt. Precies wat we willen -- zonder dit
+  // stuurt een browser geen If-None-Match en is de ETag decoratie.
+  res.set('Cache-Control', cacheControl || 'private, no-cache');
+  return res.send(body);
+}
+
 export function sendAP(res, obj, cacheControl) {
   res.type(AP_CONTENT_TYPE);
@@ -6390,5 +6430,5 @@
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
+  sendMaybe304, etagFor, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
Index: test/conditional-queues.test.js
===================================================================
--- test/conditional-queues.test.js	(revision bfd9c7325cd36c5b855d62c6a23001f6733ef4f8)
+++ test/conditional-queues.test.js	(revision bfd9c7325cd36c5b855d62c6a23001f6733ef4f8)
@@ -0,0 +1,79 @@
+// Stilte hoort niets te kosten (Barts punt, 9-8).
+//
+// De inbox doet dit al met `since` + `wait`: niets veranderd is een 304. De
+// guardian-wachtrijen stuurden bij elke verversing de hele lijst terug -- bij
+// honderd wards veertienhonderd objecten die de telefoon opnieuw moet opbouwen.
+//
+// Wat hier bewaakt wordt: dat de ETag over de INHOUD gaat (verandert er iets,
+// dan verandert hij mee) en dat een leeg antwoord NOOIT een 304 oplevert. Dat
+// tweede is dezelfde les als de '0'-uitzondering bij de inbox: gaat er iets mis
+// bij het opbouwen, dan is de hash van niets ook stabiel, en zit een client voor
+// eeuwig op 304 te kijken naar een leeg scherm.
+//
+// Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+const AP = await import('../src/services/ActivityPubService.js');
+
+function nepRes() {
+  return {
+    code: 200, body: null, headers: {},
+    set(k, v) { this.headers[String(k).toLowerCase()] = v; return this; },
+    type(t) { this.headers['content-type'] = t; return this; },
+    status(c) { this.code = c; return this; },
+    end() { this.body = ''; return this; },
+    send(b) { this.body = b; return this; },
+  };
+}
+const req = (etag) => ({ headers: etag ? { 'if-none-match': etag } : {} });
+const coll = (n) => ({ type: 'OrderedCollection', orderedItems: Array.from({ length: n }, (_, i) => ({ id: `x${i}` })) });
+
+test('dezelfde inhoud levert de tweede keer een 304 op', () => {
+  const r1 = nepRes();
+  AP.sendMaybe304(req(), r1, coll(3));
+  assert.equal(r1.code, 200);
+  assert.ok(r1.headers.etag, 'er is een merksteen');
+
+  const r2 = nepRes();
+  AP.sendMaybe304(req(r1.headers.etag), r2, coll(3));
+  assert.equal(r2.code, 304);
+  assert.equal(r2.body, '', 'en geen inhoud');
+});
+
+test('verandert er iets, dan verandert de merksteen mee', () => {
+  const r1 = nepRes(); AP.sendMaybe304(req(), r1, coll(3));
+  const r2 = nepRes(); AP.sendMaybe304(req(r1.headers.etag), r2, coll(4));
+  assert.equal(r2.code, 200, 'vier items is niet drie');
+  assert.notEqual(r2.headers.etag, r1.headers.etag);
+});
+
+test('een LEEG antwoord krijgt nooit een 304', () => {
+  // De gevaarlijke stille fout: gaat het opbouwen mis en komt er een lege lijst
+  // uit, dan is die hash ook stabiel en kijkt een client voor eeuwig naar niets.
+  const r1 = nepRes();
+  AP.sendMaybe304(req(), r1, coll(0));
+  assert.equal(r1.code, 200);
+  assert.equal(r1.headers.etag, undefined, 'geen merksteen op leegte');
+
+  const r2 = nepRes();
+  AP.sendMaybe304(req('"wat-dan-ook"'), r2, coll(0));
+  assert.equal(r2.code, 200, 'en dus ook nooit een 304');
+});
+
+test('de client krijgt te horen dat hij mag bewaren en moet navragen', () => {
+  // Zonder no-cache stuurt een browser geen If-None-Match, en is de ETag
+  // decoratie. En Vary op Authorization, want dit is een eigenaar-antwoord.
+  const r = nepRes();
+  AP.sendMaybe304(req(), r, coll(2));
+  assert.match(r.headers['cache-control'], /no-cache/);
+  assert.equal(r.headers.vary, 'Authorization');
+});
+
+test('een antwoord dat geen collectie is werkt ook', () => {
+  // Het paneel is geen OrderedCollection maar een gewoon object.
+  const r1 = nepRes(); AP.sendMaybe304(req(), r1, { wards: [], strings: { a: 'b' } });
+  const r2 = nepRes(); AP.sendMaybe304(req(r1.headers.etag), r2, { wards: [], strings: { a: 'b' } });
+  assert.equal(r2.code, 304);
+});
