gen-og.ts Cache Hid an Overwritten OG Image on 91 Posts
This article may contain affiliate links. Its content is not affected by advertising.
In short
gen-og.ts wrote both languages to the same og/${slug}.png, so the later ja run overwrote en, and every check stayed green because none of them compared the bytes of the two images.
Conclusion
gen-og.ts generated OG images for both the Japanese and English posts, but wrote them to a single shared path, og/${slug}.png. Japanese and English posts share the same slug, so whichever language ran second overwrote the one that ran first. On top of that, the incremental cache saw “hash matches, file exists” and decided nothing had changed, so it never rebuilt the overwritten side. 91 of 95 slugs ended up with byte-identical Japanese and English images. The page rendered fine and og:image returned 200 the whole time, so every check stayed green.
Symptom
gen-og.ts walks the post directories like this:
for (const lang of readdirSync(POSTS)) {
const dir = join(POSTS, lang);
if (!statSync(dir).isDirectory()) continue;
for (const file of readdirSync(dir)) {
// ...
POSTS contains two directories, en/ and ja/, processed in whatever order readdirSync returns them. Before the fix, the output path didn’t depend on language at all:
const out = join(OUT, `${slug}.png`);
Since a Japanese and an English post form a translation pair sharing one slug, en/xxx.md and ja/xxx.md both wrote to the identical file, og/xxx.png. The page itself rendered correctly, and the URL that <meta property="og:image"> pointed to returned 200, so ordinary crawl-based checks found nothing wrong. No check compared whether the actual image bytes differed between the two languages — that check simply didn’t exist yet.
Cause
In this repository, readdirSync(POSTS) happened to return en before ja. The fix commit’s own comment spells out exactly what was going on:
// ⚠ Until 2026-09-05, ja and en wrote to the same filename and stomped on each other.
// There was only one output, og/${slug}.png, and since both languages share a slug,
// whichever ran later (ja, in readdir order) overwrote the other.
// The cache key is lang/slug — 191 entries across both languages — but only 96 files
// actually existed on disk. The cache kept insisting it had "built" them, so the
// overwrite went unnoticed.
Because ja ran last, what remained in the shared file og/${slug}.png was the Japanese-titled image. Meanwhile, Post.astro’s og:image built the same URL regardless of language, before the fix:
{/* ⚠ English should read og/en/. Since ja and en share a slug, using the same path
lets the generator stomp on itself, leaving the Japanese title image on
English pages (fixed 2026-09-05) */}
ogImage={`${site}/og/${entry.data.lang === 'en' ? 'en/' : ''}${entry.id.replace(/^(ja|en)\//, '').replace(/\.md$/, '')}.png`}
As the comment says, the pages that suffered were the English share cards. English pages referenced the same og/${slug}.png, so what was drawn there wasn’t their own language at all — it was the Japanese title.
What made it worse was the incremental cache. Its key was the lang/slug pair, and it held 191 entries across both languages. But only 96 files actually existed under og/ (95 slugs plus one default image). During the en build, the English image did get written once — and its hash got recorded — but immediately afterward the ja build overwrote the same path. On every subsequent build, the cache looked at the en entry, saw the hash still matched and the file still existed, and judged “unchanged” — skipping regeneration forever. Once broken, it never had a chance to repair itself.
In practice, 91 of 95 slugs ended up with identical bytes on both language sides.
The fix
Three changes went in.
- Split the English output into its own path,
og/en/${slug}.png:
const out = lang === 'en' ? join(OUT, 'en', `${slug}.png`) : join(OUT, `${slug}.png`);
mkdirSync(dirname(out), { recursive: true });
Post.astro’sog:imagenow points to the per-language path too:
ogImage={`${site}/og/${entry.data.lang === 'en' ? 'en/' : ''}${entry.id.replace(/^(ja|en)\//, '').replace(/\.md$/, '')}.png`}
- The incremental cache was discarded once, and all 190 OG images across both languages were regenerated from scratch. The 96 Japanese-side files (including the default image) weren’t actually broken, but since the cache key itself had been contaminated, everything was rebuilt without trying to distinguish which files could still be trusted.
Alongside the fix, a new check:og was added to mechanically catch a repeat of the same failure:
// ⚠ Same slug, identical bytes in ja and en = one of them got overwritten
const collided: string[] = [];
for (const s of en) {
const a = `${OG}/${s}.png`;
const b = `${OG}/en/${s}.png`;
if (!existsSync(a) || !existsSync(b)) continue;
if (readFileSync(a).equals(readFileSync(b))) collided.push(s);
}
It fails both when an article has no corresponding image and when the Japanese and English images for the same slug are byte-for-byte identical. As a positive control after the fix, the English image was deliberately overwritten with the Japanese one to confirm this check does catch it.
Preventing a repeat
The mechanism that now catches this class of failure landed with this fix, but the source commits don’t say whether the same “URL returns 200, page renders fine, but the content is wrong” failure mode could happen to other generated assets too.
What’s structurally visible here is that an artifact that should have been split per language wasn’t distinguished by language at the path-design stage. And because the incremental cache operates on the assumption of “don’t rebuild if nothing changed since last time,” once an overwritten, broken state got remembered as “correct,” no later build could self-heal it. A check that only looks at page rendering or URL reachability can never detect this class of bug in principle — what this incident made clear is that you need a check that compares the actual bytes of the generated files themselves.
Frequently asked questions
Q1Why did every check stay green while this was broken?
The og:image URL returned 200 and the page itself rendered correctly. The existing checks only looked at URL reachability and page structure — nothing compared whether the Japanese and English image files were actually different bytes.
Q2Which side's image was the one that broke, English or Japanese?
gen-og.ts processed article directories in readdir order rather than ja-then-en, and in this repo that order ran en first, ja second. Both wrote to the same og/${slug}.png, so whichever finished last — ja — was the content left behind, meaning English share cards showed the Japanese title.
Q3What happened to the existing images after the fix?
The cache was discarded once, and all 190 OG images for both languages were regenerated from scratch. The cache key itself was corrupted, not just the English output, so the Japanese side was rebuilt too rather than sorting out which files were still trustworthy.
Environment verified
- Astro ^7.2.2 / satori ^0.29.0 / sharp ^0.35.3 / Node >=22.12.0
- Occurred and fixed on 2026-09-06 in our own blog build pipeline
What this article is based on
- TypeScript file lines 113-140commit b826c5e
- TypeScript file lines 1-68commit b826c5e
- Astro file lines 224-231commit b826c5e
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.