One of the two links is always missing
A syndicated copy links home immediately; the original links out only after a rebuild. The asymmetry means one direction is reliably broken for a while.
Syndication is supposed to produce a pair: the copy carries a canonical link home, and the original carries a link out to the copy. Two references, one relationship.
The first happens at the moment of publishing. The second happens the next time somebody rebuilds the site, which may be days.
The case this post is about
One pair of links that are supposed to point at each other:
publish the copy; the platform returns an identifier
record it into the original's own file, immediately, as the next statement
deploy deliberately, as a named step, after a batch rather than per post
verify both directions resolve, once, after the deploy
The copy points home from the moment it is created — the canonical tag is in the payload. The original cannot point at the copy until the identifier exists, which is after the copy is published, and cannot show it until the site is rebuilt.
So there is a window where the relationship is one-directional. Nobody plans for it, nothing reports it, and the length of it is however long until somebody next deploys.
This post follows those four steps, because the order is the whole fix and the second line has to be the next statement after the first.
The symptom
A reader on the original has no idea the other version exists, and a reader on the copy has no reason to think the original is the better place to be. The relationship is real, recorded, and visible from exactly one side.
The second thing is where the delay comes from, which is structural rather than careless. The identifier that the copy exists is returned by the platform at creation time and written into the post's own file. A static site renders that file at build time. So the information is in the repository immediately and in front of readers only after a deploy — and nothing in the publishing step triggers one, because publishing is about the other platform.
The third is that the window is invisible from both ends. The copy looks complete; it has its canonical and it resolves. The original looks complete; it is live and correct. Nothing is broken in a way any check would report, and the only missing thing is a link that does not exist yet.

The fourth is the quiet consequence. During that window every reader arriving at the original is a reader who might have followed the link to the copy's audience, and every reader on the copy sees a post that appears to live there. The direction of attribution that the canonical establishes for machines is undermined, for humans, by the absence of the return link.
Those two audiences are worth separating deliberately, because they are served by different mechanisms with different timing. Machines read the canonical and settle attribution early and durably. People read a visible link and are served only once the page has been rebuilt. Designing for one and assuming the other follows is how the human half ends up as an afterthought.
Why the obvious fix failed
Add the link by hand after publishing. Reliable exactly as often as any manual step that happens in a different application from the one you are in. It also multiplies: every post, every time, with no signal when it is skipped.
Rebuild the site after every publish. Correct and heavier than it sounds when publishing is batched. It also puts a deploy in the middle of a sequence whose failure modes you have carefully made safe, and a failed deploy mid-batch leaves a state nobody designed.
Working out what that state actually is, before it happens, is worth twenty minutes. Some copies exist with their identifiers recorded and unrendered; the site is still serving the previous build; and the next run has to know not to re-create anything. All of that is survivable if it was anticipated and genuinely confusing if the first time anybody thinks about it is while it is happening.
Fetch the link client-side. Renders the problem away and introduces a request on every page view to a platform that does not expect one, to display a link whose value is modest. The cost lands on every reader to fix a window that affects some of them.
Skip the return link. Defensible — the canonical is the part that matters for attribution. It also discards the one mechanism by which an original gets readers from a copy, which is most of why syndicating is worth doing at all.
The actual mechanism
Treat the pair as one relationship with two writes at different times, and make the second write explicit rather than incidental.
publish the copy; the platform returns an identifier
record it into the original's own file, immediately, as the next statement
deploy deliberately, as a named step, after a batch rather than per post
verify both directions resolve, once, after the deploy
The fourth line is the one that converts this from hope into a property. Checking that the canonical resolves is a request against your own site; checking that the return link is present is a request against your own page. Both are available, neither is expensive, and running them after the deploy is what distinguishes "the data is in the repository" from "a reader can see it".

The sub-case worth naming is the ordering within the pair. The canonical must resolve before the copy is created, because the platform fetches it at creation. The return link can only be written after the copy exists, because it needs the identifier. So the two halves of one relationship have opposite ordering requirements, which is why they cannot be done in a single step and why treating them as one action produces one of them being wrong.
That constraint is worth stating in the code rather than in somebody's head, because the natural instinct when tidying a publishing script is to move the two writes next to each other. They look like they belong together; they cannot be together; and a comment saying why is the cheapest possible defence against a future simplification.
There is a related asymmetry in durability. The canonical lives in the copy, on a platform you do not control, and is fixed at creation. The return link lives in your own file and can be corrected at any time. That difference should shape where the care goes: get the canonical right the first time because it is close to immutable, and treat the return link as the recoverable half.
Sorting the two halves that way changes what deserves a check before versus after. Anything immutable earns a verification that runs before the irreversible step; anything recoverable earns a verification that runs afterwards and can simply be re-run. Spending equal care on both is a way of spending too little on the half that cannot be fixed.
The fix
order deploy the original, then create the copy, then record the identifier
batch publishes, then deploy once, then verify both directions
check the canonical resolves, directly, before the copy is created
check the return link renders, after the deploy, on the live page
Both checks are HTTP reads of the rendered pages, because that is what a crawler sees — the frontmatter being correct is not the claim:
export async function assertBidirectional(post: Post) {
// 1. The canonical resolves DIRECTLY — no redirect. The copy stores the URL it
// was handed; a 301 today can be a 404 later, after the copy is permanent.
const canon = await fetch(post.canonical_url, { redirect: "manual" });
if (canon.status !== 200) {
throw new Error(`canonical ${post.canonical_url} -> ${canon.status} (no redirect allowed)`);
}
// 2. The RETURN link renders on the live canonical page. This only becomes true
// after a deploy that followed the remote_url being recorded, which is why it
// is a separate check and not an assumption.
const html = await canon.text();
if (!post.remote_url) throw new Error(`${post.slug}: no remote_url recorded yet`);
if (!html.includes(post.remote_url)) {
throw new Error(
`${post.slug}: canonical page does not link back to ${post.remote_url}\n` +
` the frontmatter has it; the deployed page does not — redeploy`);
}
return { ok: true };
}
The failure this catches is the one that looks finished:
$ verify-links --all
✗ three-phases: canonical page does not link back to
https://medium.com/@me/three-phases-9f2c1b05
the frontmatter has it; the deployed page does not — redeploy
152 ok · 1 failing
Reconcile had written the URL into fifteen posts and nobody had redeployed. One direction of the pair was live and the other existed only on disk, which from a crawler's point of view is the same as not existing at all.
The third and fourth lines check different things at different times and both are one request. The first guards against donating attribution to the copy; the second guards against the silent window this post is about. Neither is clever and the pair of them is the entire mechanism.
What makes the batching tolerable is that the window stops being accidental. A deploy that happens deliberately after a batch is a known gap of a known length; a deploy that happens whenever somebody next pushes is an unknown gap nobody is tracking. The same delay, honestly scheduled, stops being a defect.
It also becomes something you can state to a reader if it ever matters. A known gap can be described — "the link appears after the next deploy" — whereas an unknown one can only be apologised for after somebody notices.
How we proved it
The asymmetry is observable in one pair of requests. Fetch the copy and find the canonical; fetch the original and look for the outbound link. Immediately after publishing, the first is present and the second is not, and that is the whole demonstration.

The repository-versus-rendered distinction is easy to confirm, and it is the part that surprises people. The identifier is in the post's file, visible in the editor, committed — and absent from the page a reader loads. Looking at the file is not looking at the site, and this is the cheapest possible reminder of that.
What I cannot tell you is what the window costs. I have no measurement of readers who arrived at an original during the gap and would have followed a link that was not there. The argument is that the link exists for a reason and its absence removes that reason for a period — not that I have sized the loss.
What I have not solved: posts syndicated before the return link existed as a field. They have canonicals and no outbound link, and backfilling means finding each copy's identifier and writing it into each original — mechanical, tedious, and sitting undone, which is the same fate as most backfills.
What makes this one slightly more likely to happen than most is that the data needed is already recorded — the identifiers came back at creation time and were written down. The backfill is a transformation of something I have rather than a recovery of something I lost, which is the difference between tedious and impossible.
And a limitation of the return link itself: it is a line of text on a page, and most readers do not read lines of text on pages. Its value is real and modest, and I would not want anybody to reorganise a publishing pipeline around it alone. The reason it is worth the care is that it is cheap once the ordering is right, not that it is transformative.
Being clear about that matters when deciding how much machinery to build around it. A modest benefit with a cheap mechanism is worth having; the same benefit defended with a browser-side fetch or a deploy per post is not, and the temptation to escalate comes from forgetting how small the prize is.
The thing that reframed it was noticing I had been thinking of the relationship as a single thing with a single moment. It is two writes, to two systems, with opposite ordering constraints and different durability — and almost every difficulty described here follows from having treated that as one action called "syndicate".
What this generalizes to
A two-way relationship is two one-way writes, and they almost never happen at the same time or with the same reliability.
The pattern is everywhere two systems reference each other. A webhook registered with a provider and a provider recorded in your config. A friendship, a follow, a link between records in two services. A deployment that knows its commit and a commit that is supposed to know its deployment. In each case one direction is written at the moment of the action and the other is written later, by something else, under conditions nobody enumerated.
So the question worth asking of any bidirectional relationship is which direction is written second, and what happens between — because that gap is where the relationship exists in one direction only, and its length is usually set by something unrelated to the relationship itself.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".