Index: docs/shaer-c2s-api.md
===================================================================
--- docs/shaer-c2s-api.md	(revision 31e63d1251e32cc941a17d3c7970d25df4953730)
+++ docs/shaer-c2s-api.md	(revision f889429b240a7ad9a035d0ffcb48a6a62003b813)
@@ -96,8 +96,19 @@
 - Send neither and the route behaves exactly as it always did.
 
-The answer arrives as soon as anything the inbox would show has changed, or when
-`wait` runs out — whichever comes first. Either way it is the full, current
-collection with a fresh `shaer:cursor`. An empty-handed return is not an error:
-it means nothing happened, ask again.
+Two possible answers:
+
+| | |
+|---|---|
+| **`200`** | something changed. The full, current collection with a fresh `shaer:cursor`. |
+| **`304`** | nothing changed within `wait`. **No body.** Keep the cursor you have and ask again. |
+
+`304` is not an error — it is the normal answer to a quiet minute, and it is why
+this costs nothing while nothing happens. Sending the whole timeline back every
+`wait` seconds just to say "still nothing" would be a poor trade for saving one
+round trip.
+
+A server that has not run the feed-state migration yet cannot tell change from
+stillness, and answers `200` with the collection every time rather than `304`
+forever. Slower, never wrong.
 
 The cursor moves for **all four** sources this read merges: the timeline,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 31e63d1251e32cc941a17d3c7970d25df4953730)
+++ src/routes/activitypub.js	(revision f889429b240a7ad9a035d0ffcb48a6a62003b813)
@@ -285,8 +285,22 @@
     const afbreken = new AbortController();
     res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
-    await AP.waitForFeedChange(auth.site.slug, {
+    const uit = await AP.waitForFeedChange(auth.site.slug, {
       since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
     });
     if (res.writableEnded || afbreken.signal.aborted) return undefined;
+    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
+    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
+    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
+    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
+    // dat voor nieuws twee rondjes nodig heeft.
+    //
+    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
+    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
+    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
+    // een lege merksteen sturen we dus gewoon de collectie.
+    if (!uit.changed && uit.cursor !== '0') {
+      res.set('Vary', 'Authorization');
+      return res.status(304).end();
+    }
   }
   // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
