Why this site went black at the last section
Two black pages, two causes: a texture loaded from a CDN with no error boundary, then a cached rate-limit error. A post-mortem.
Visitors on slow connections reported that the page turned black when they scrolled to the Resume and Socials sections. On my machine it never happened.
A bug that only appears for other people is the expensive kind. This one took an evening, and nine days later the site went black again for a completely different reason. Both are written up here, because the second one is the more useful lesson.
The first cause
The Socials section has a small 3D sky with clouds from the drei library. I had passed a bundled texture to <Cloud texture={...}>. That prop does not exist on Cloud; it belongs to the Clouds wrapper. A bare Cloud silently wraps itself in Clouds with drei's default texture URL, which points at a CDN.
When that CDN was slow or blocked, the texture loader threw inside the React Three Fiber tree. Nothing above it caught the error, so React did what it does with an uncaught render error: it unmounted the whole application. The body background is black. Hence a black page.
It showed up "at Resume" because the Socials canvas mounts as soon as it is 10% visible, which is while Resume is still on screen.
The fix, in three layers
<Clouds texture={localCloud} limit={360}>around the puffs, so nothing external is loaded. Thelimitmatters too: the default of 200 was silently dropping 160 of the 360 cloud segments.- An error boundary around every section and both 3D canvases. A broken section now degrades to an empty block that keeps its id, so navigation still works.
- Every remaining external asset (four CSS textures) replaced with local files. The site makes zero third-party requests.
The second black page
On 11 November the Events page went black on desktop, and one photo in the gallery showed as an empty frame. No code had changed in either.
The network tab told the story. The script for the Events page and that one image were both answered with HTTP 429, "too many requests". The page is loaded on demand, so when its script failed there was nothing to render, and the boundaries from the first fix sat inside the page that never arrived.
A 429 should be a passing condition. This one was not passing. Days later the same image still returned 429, on a single request, from a different network.
A cached error
The response headers explained it:
HTTP/1.1 429 Too Many Requests
Cache-Control: public, max-age=31536000, immutable
cf-cache-status: HIT
Files under /assets/ have a content hash in their names, so I had told the server to mark them cacheable for a year. That is standard advice and it is correct for a successful response. The server applied the same header to the error response. The CDN in front of the site then did exactly what it was told: it stored the 429 as the permanent, immutable content of that address and served it to everyone.
The host's rate limiter had fired once, probably during a deploy or while I was taking screenshots of several pages in a row. One unlucky second became a file that was broken for a year.
I then made it worse. To check the fix I wrote a script that requested every file on the live site, 250 of them, with a small gap between requests. That burst tripped the limiter again and poisoned most of the assets at once. The site was broken for about fifteen minutes before I saw what my own checker had done.
The fix, again in layers
- Cap what the CDN may keep. Assets now carry
max-age=31536000, s-maxage=300, immutable. Browsers still cache a good file for a year. The shared cache holds anything, including an error, for five minutes at most. - New addresses for everything. A revision suffix on every asset name meant the poisoned URLs were simply never requested again.
- Retry on the client. Pages that load on demand now retry three times, then reload once, then show a short message with a reload button instead of a black screen. Images that fail are requested once more under a fresh address.
- Fewer requests per page. Twenty-four client logos became one combined image, so a page load is less likely to look like a burst.
- A checker that cannot cause the fault. One request every one and a half seconds, and it stops at the first 429 instead of retrying.
The lessons
Decorative features must never be able to take down the page. If a canvas cannot create a WebGL context or a texture fails, the right outcome is "no clouds", not "no site".
Cache headers apply to whatever response they are attached to. "Immutable for a year" is a promise about the content, and the server will make that promise on behalf of an error page if you let it. Check what your server sends on a 404, a 429 and a 500, not only on a 200.
A monitoring tool is a client like any other. If it can trigger the limit it is watching for, it will, at the worst moment.
The service these lessons feed into is company website development. The systems are listed under work.