Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision c52dc82434331354ded181d0905a44614892a72e)
+++ src/services/ActivityPubService.js	(revision 3f32994e887da124cb2b8928840846ebefb11c87)
@@ -2849,5 +2849,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 17; // v17: re-resolve link previews now that OpenGraph works and big pages are read instead of refused
+const SELFHEAL_VERSION = 18; // v18: link previews no longer hang behind the note re-fetch (an unreachable origin skipped the whole row)
 async function fetchNoteAP(url) {
   try {
@@ -3162,4 +3162,15 @@
     let healed = 0, failed = 0;
     for (const r of rows) {
+      // Link previews first, and deliberately BEFORE the note re-fetch. A
+      // preview is resolved from the content we already hold, so hanging it
+      // behind a remote fetch meant one unreachable origin skipped the whole
+      // row (`continue` below) and the card never appeared. It needs nothing
+      // from the origin, so it must not depend on it.
+      if (!r.quote_json && !r.embed_json) {
+        try {
+          const ej = await resolveExternalEmbed(r.content);
+          if (ej) db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id);
+        } catch { /* best-effort, never blocks the heal */ }
+      }
       try {
         const note = await fetchNoteAP(r.id);
@@ -3185,12 +3196,4 @@
           const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
           if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
-        }
-        // v16: link previews. A post from before the embed pipeline has no
-        // card at all, which is why nothing showed. Only for rows that have no
-        // quote (a quote already IS the card) and no embed yet, so this costs
-        // one page fetch per candidate and never repeats.
-        if (!r.quote_json) {
-          const ej = await resolveExternalEmbed(html || r.content).catch(() => null);
-          if (ej) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id); } catch { /* ignore */ } }
         }
         // v14: same for the booster's display name ("X boosted"). The row stores
