· Engineering · 4 min read
My site was broken for 1018 days and looked perfectly fine
A gitignored lockfile fossilised my personal site in November 2023. Every visitor saw a working page; the build had not succeeded once in nearly three years. A green site does not prove a green build.

On 16 November 2023, this site deployed successfully for the last time.
On 19 December 2023 I pushed an upgrade to Astro 4. It failed. I pushed again. It failed. I pushed a third time, it failed again, and then — I assume, because I genuinely do not remember — I got distracted and moved on to something else.
Vercel kept serving the November build. For 1018 days.
Nobody noticed, including me. The site loaded. The links worked. The blog was there. Recruiters read it. I linked it in emails. It was, by every signal available from the outside, a perfectly healthy website. It just could not be built anymore.
The thing that actually broke
My .gitignore had this line in it:
package-lock.jsonThat is the whole bug. Everything else follows from it.
Without a committed lockfile, nothing pins your dependency tree. My package.json asked for "@astrojs/sitemap": "^3.0.3", and a caret range means “anything up to the next major”. So the day I ran the build, npm happily resolved it to 3.7.3, a version released long after I stopped looking, which requires Astro 5. I was on Astro 4.
The build died here:
[ERROR] [vite] ✗ Build failed
Cannot read properties of undefined (reading 'reduce')
at astro:build:done (@astrojs/sitemap/dist/index.js:85:37)I did not change a line of code. The code changed underneath me, because I had told git not to remember which versions worked.
Why three years went by
This is the part I find genuinely interesting, and it is not really about npm.
A static site on a CDN has a property that server-rendered apps do not: the last successful build outlives the code that produced it. Vercel had a working artifact from November, so it kept serving it. Forever. There is no ambient signal that anything is wrong, because from the outside a cached success and a healthy pipeline are byte-for-byte identical.
Compare that to a server that fails to boot. You find out in minutes. Here, the failure is silent by construction, and the only place it surfaces is a dashboard you have no reason to open, because — again — the site looks fine.
So the failure mode is not “my build broke”. It is: my build broke, and the system was designed to hide that from me for as long as possible.
What was rotting behind the cache
Once I finally rebuilt it, the fossil turned out to be carrying passengers. None of these were visible either:
- Analytics had been silently collecting nothing. The Google Analytics scripts were tagged
type="text/partytown", which tells the browser not to execute them and hand them to Partytown instead. Partytown was never registered. Both halves were individually plausible. Together they meant the tags sat inert on every page. - Dark mode rendered black headings on a near-black background. I had set the heading colour in a
ui.tokensblock in my config that nothing ever read. Dead config fails quietly: you write the value, you feel finished, and the default silently stays. target="_blank"did nothing. At some point I had edited the button component and dropped the line that emits the attribute. Every external link on the site kept passingtarget: '_blank'and every one of them opened in the same tab.- Three recommendation avatars had been 403ing for 969 days. They were hotlinked from LinkedIn’s CDN, whose URLs carry a signed expiry. Mine expired on 4 January 2024.
- My CV listed the wrong job title for a role I held for nearly four years.
Every one of these had been live, in public, in front of everyone who visited.
The lesson I actually take from this
Not “commit your lockfile”, though: commit your lockfile.
The real one is that a deployed site tells you nothing about whether it can still be deployed. Those are two different systems and only one of them is being checked when you glance at your own homepage and think “yep, still there”.
If a repo matters to you, the cheapest possible insurance is a scheduled job that does nothing but npm ci && npm run build on a clean checkout, once a week, and shouts when it stops working. Not because the build is fragile, but because the day it breaks is not the day you will find out.
I would have found out in seven days instead of 1018.
The expiry that almost got me twice
One footnote, because it made me laugh.
While fixing the broken avatars, I asked for replacements and was handed fresh LinkedIn CDN links. They looked fine. They loaded.
They carried e=1789603200 in the query string. That decodes to 17 September 2026 — seventeen days out. Hotlinking them would have reproduced the exact bug I was in the middle of fixing, on a three-week fuse.
They are committed files now. 25 kB each, converted to WebP at build time, served from my own domain. The site no longer makes a single image request to anyone else’s server.