Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 0e27e543093e30188f40dd3a927bb465c26d950f)
+++ src/routes/activitypub.js	(revision 14f7cb2a1b5add6334373b9865b7bfb7e4e2bc66)
@@ -285,5 +285,10 @@
   const playbackAllowed = embedsAllowed
     && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
+  const rows = AP.getTimeline(auth.site.slug, 60);
+  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
+  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
+  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
+  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
+  const posts = rows.map((t) => ({
     id: `${t.id}#create`,
     type: 'Create',
@@ -333,6 +338,6 @@
       // Whether THIS account already liked/boosted the note, so the app's
       // detail-view buttons show the current state (and can toggle/undo).
-      'shaer:liked': !!t.liked,
-      'shaer:boosted': !!t.boosted,
+      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
+      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
       // An external (non-fediverse) embed, thumbnail-only and never an iframe.
       // Omitted entirely when the gate is closed (see above).
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 0e27e543093e30188f40dd3a927bb465c26d950f)
+++ src/routes/posts.js	(revision 14f7cb2a1b5add6334373b9865b7bfb7e4e2bc66)
@@ -806,5 +806,5 @@
     liked: !!req.query.liked,
     boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
+    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
     siteTitle: site ? site.title : '',
   });
@@ -839,5 +839,5 @@
   let on = false;
   if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
+    on = !ActivityPubService.getReaction(site.slug, uri).liked;
     ActivityPubService.resolveRemoteNote(uri)
       .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
@@ -857,5 +857,5 @@
   let on = false;
   if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
+    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
     ActivityPubService.resolveRemoteNote(uri)
       .then((note) => {
@@ -1280,5 +1280,5 @@
   let on = false;
   if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
+    on = !ActivityPubService.getReaction(site.slug, note).liked;
     try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     ActivityPubService.setReaction(site.slug, note, 'like', on);
@@ -1294,5 +1294,5 @@
   let on = false;
   if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
+    on = !ActivityPubService.getReaction(site.slug, note).boosted;
     try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 0e27e543093e30188f40dd3a927bb465c26d950f)
+++ src/services/ActivityPubService.js	(revision 14f7cb2a1b5add6334373b9865b7bfb7e4e2bc66)
@@ -3429,4 +3429,42 @@
  * werk voor fase 2, mét datamigratie.
  */
+/**
+ * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
+ * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
+ * dezelfde vraag beantwoordden uit twee verschillende bronnen.
+ */
+export function getReaction(slug, uri) {
+  try {
+    const rows = (slug && uri)
+      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri)
+      : [];
+    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
+  } catch { return { liked: false, boosted: false }; }
+}
+
+/**
+ * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
+ * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
+ * een consistentiebug geruild voor een traagheidsbug.
+ */
+export function getReactionsFor(slug, uris) {
+  const out = new Map();
+  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
+  if (!slug || !list.length) return out;
+  try {
+    const rows = db.prepare(
+      `SELECT target_uri, kind FROM ap_my_reactions
+        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
+    ).all(slug, ...list);
+    for (const r of rows) {
+      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
+      if (r.kind === 'like') cur.liked = true;
+      if (r.kind === 'boost') cur.boosted = true;
+      out.set(r.target_uri, cur);
+    }
+  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
+  return out;
+}
+
 export function setReaction(slug, uri, kind, on, opts = {}) {
   if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
@@ -4809,5 +4847,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, setReaction, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, setReaction, getReaction, getReactionsFor, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Index: test/reactions-characterization.test.js
===================================================================
--- test/reactions-characterization.test.js	(revision 0e27e543093e30188f40dd3a927bb465c26d950f)
+++ test/reactions-characterization.test.js	(revision 14f7cb2a1b5add6334373b9865b7bfb7e4e2bc66)
@@ -212,2 +212,44 @@
   assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
 });
+
+// ── Fase 2: lezen komt uit de tussentabel ────────────────────────────────
+
+test('getReaction leest de tussentabel, niet de kolom', () => {
+  const u = uri('g1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: false });
+  AP.setReaction('me', u, 'boost', true);
+  assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: true });
+});
+
+test('WAAROM DE BACKFILL EERST MOET: een kale kolomvlag is onzichtbaar voor getReaction', () => {
+  // Dit is de reden dat scripts/backfill-reactions.mjs tussen fase 1 en 2 hoort.
+  // Een reactie van vóór fase 1 staat alleen in de kolom; zodra de lezers de
+  // tussentabel volgen is die stil verdwenen -- geen fout, geen spoor.
+  const u = uri('g2'); seedTimeline(u);
+  AP.markLiked('me', u);                                  // zoals de oude tijdlijn-route
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
+  assert.equal(AP.getReaction('me', u).liked, false, 'maar het nieuwe leespad ziet hem niet');
+  // Wat de backfill doet:
+  AP.setMyReaction('me', u, 'like', true);
+  assert.equal(AP.getReaction('me', u).liked, true, 'na aanvullen wel');
+});
+
+test('getReactionsFor haalt een hele pagina in één keer op', () => {
+  const a = uri('g3'), b = uri('g4'), c = uri('g5');
+  seedTimeline(a); seedTimeline(b);
+  AP.setReaction('me', a, 'like', true);
+  AP.setReaction('me', b, 'boost', true);
+  const m = AP.getReactionsFor('me', [a, b, c]);
+  assert.equal(m.get(a).liked, true);
+  assert.equal(m.get(b).boosted, true);
+  assert.equal(m.get(c), undefined, 'wie niets heeft komt niet in de map; de aanroeper valt terug op false');
+  // Dezelfde uitkomst als per stuk vragen, zodat de batch geen eigen waarheid wordt.
+  for (const u of [a, b]) assert.deepEqual(m.get(u), AP.getReaction('me', u));
+});
+
+test('getReactionsFor: lege of rommelige invoer geeft een lege map', () => {
+  assert.equal(AP.getReactionsFor('me', []).size, 0);
+  assert.equal(AP.getReactionsFor('me', null).size, 0);
+  assert.equal(AP.getReactionsFor('', [uri('g3')]).size, 0);
+});
