Nakodo shows brands a list of creators from YouTube, Instagram and TikTok, and every row has a round picture next to it. That picture is the single most failure prone thing on the page, for a reason that has nothing to do with our code: Instagram and TikTok sign their avatar URLs, and the signature is good for a few days.

We store a creator for weeks. So a creator found a fortnight ago, shown to a brand today, has an avatar URL that now answers 403 and will never work again.

This post is about the four places that fact reaches.

1. The fallback has to be a design decision, not an empty box

When there is no avatar, or the one we have has expired, the circle is hatched:

if (!src || failed === src) { return <div className={cn(box, "hatch")} style={{ width: px, height: px }} aria-hidden="true" />; } 

hatch is one utility, used across the whole app for exactly one meaning:

/* Hatching always means "locked" or "not yet observed", and always comes with text. */ @utility hatch { background-image: repeating-linear-gradient(45deg, var(--hatch) 0 1px, transparent 1px 4px); } @media (forced-colors: active) { .hatch { background-image: none; border: 1px dashed CanvasText; } } 

A background image disappears entirely in forced colours mode, so there is a dashed border waiting there instead. You can see the hatched circle without signing in: the sample entries on our home page use it, because a sample creator has no picture to show.

The aria-hidden matters too. An avatar is decoration next to a name that is already text; a screen reader announcing "image" here is noise, and an alt text saying the creator's name would announce it twice.

2. onError does not fire for an image that already failed

This was the bug that sent me reading the HTML spec. The obvious handler is fine when the image loads and then fails:

onError={() => setFailed(src)} 

But server rendered HTML means the browser often starts the request before React hydrates. If it finishes failing first, the error event has already been dispatched, React attaches its listener afterwards, and nothing ever calls your handler. You get a broken image icon and no state change.

The fix is in the ref callback, which runs on an element that already exists:

ref={(img) => { // One that failed before the page came to life never fires onError. if (img?.complete) img.decode().catch(() => setFailed(src)); }} 

complete is true for both "loaded" and "finished failing", so it is not the test on its own. decode() is: it resolves if the browser has a usable image and rejects with an EncodingError if it does not. Two lines, and they cover the case the event handler structurally cannot.

3. Two kinds of picture, two code paths, one allowlist

Creator avatars come from a known set of hosts, so they go through Next's optimiser:

images: { remotePatterns: [ { protocol: "https", hostname: "yt3.ggpht.com" }, { protocol: "https", hostname: "yt3.googleusercontent.com" }, { protocol: "https", hostname: "i.ytimg.com" }, { protocol: "https", hostname: "**.tiktokcdn.com" }, { protocol: "https", hostname: "**.tiktokcdn-eu.com" }, { protocol: "https", hostname: "**.tiktokcdn-us.com" }, { protocol: "https", hostname: "**.cdninstagram.com" }, { protocol: "https", hostname: "**.fbcdn.net" }, { protocol: "https", hostname: "platform-lookaside.fbsbx.com" }, ], } 

Nine entries, and the list is a security boundary rather than a convenience: /_next/image fetches whatever URL you hand it, from our server, so an unconstrained remotePatterns is an open proxy with our egress bill attached.

The second kind of picture is a business's own icon, taken from its website, and a business can be on any domain on the internet. There is no allowlist that could cover that, so those bypass the optimiser entirely:

// eslint-disable-next-line @next/next/no-img-element <img src={src} alt="" loading="lazy" referrerPolicy="no-referrer" onError={() => setFailed(src)} ref={(img) => { if (img?.complete) img.decode().catch(() => setFailed(src)); }} /> 

referrerPolicy="no-referrer" is the part I would not skip. Without it, every brand looking through a list of local businesses sends that business's web server a referrer header naming the page of our app they were on. A plain <img> is also the one case where disabling the lint rule is right rather than lazy, which is why the line above it says so.

4. One optimised size for four display sizes

The avatar component takes a size name, and the sizes are 28, 36, 48 and 72 pixels. The naive implementation asks the optimiser for each of them, and then one creator who appears in a list, a detail header and a conversation is three resize operations and three cache entries.

So the request is always the same:

// Every size asks the optimiser for the same picture, 64px across (128 on a // sharp screen), so a creator's avatar is resized and billed once rather than // once per size it's shown at. The largest, 72px, is barely softer for it. const OPTIMISED_PX = 64; 

<Image width={64} height={64}> with a CSS size applied through style means Next generates a srcset of 64 and 128 (both are in the default imageSizes), and the 72px avatar is drawing a 128px image, which is fine. The only visible cost is that the largest size is very slightly soft on a 1x screen.

The cache TTL is the other half of that:

// An avatar's address changes when the picture does (Instagram and TikTok // sign theirs afresh on each read), so a resized one is kept for a week // rather than resized again every 4 hours. minimumCacheTTL: 7 * 24 * 60 * 60, 

Next 16 raised the default from 60 seconds to 4 hours, which is a sensible default for images whose URL stays put while the bytes change. Ours is the opposite: the URL is a fresh signed one every time the picture changes, so re-fetching the old URL on a timer can only ever produce the same bytes or a 403. A week is the useful number, and it has a ceiling we do not get to pick: YouTube's API terms let us keep their data for 30 days, so any cache we hold has to be comfortably inside that. Our retention rules are public, and the image cache is quietly part of them.

Also in that config block, and doing nothing at all:

qualities: [75], 

That is Next 16's default now, spelled out. It stays because the next person to read the file should see which quality the optimiser is allowed to produce without having to know a version specific default.

5. And then the logs

With all of the above, an expired avatar is handled correctly: the request fails, the component falls back to a hatched circle, the user sees a tidy list. The only thing left is that the optimiser logs every refused upstream fetch as an error, and in production, with a list of creators gathered over several weeks, that is a lot of error lines for an outcome that is both expected and already handled.

The entire contents of src/instrumentation.ts:

// Instagram and TikTok sign their avatar links for a few days, so a creator // kept from an earlier search can have one that now answers 403. The avatar // falls back to its hatched circle, but Next's image optimiser still logs // every refused fetch as an error. Those lines are dropped; everything else // goes through untouched. export function register() { if (process.env.NEXT_RUNTIME !== "nodejs") return; const error = console.error.bind(console); console.error = (...args: unknown[]) => { if (args.some((arg) => typeof arg === "string" && arg.includes("upstream image response failed for"))) return; error(...args); }; } 

Thirteen lines, one of which is a guard so it does not run in the edge runtime, and the rest of which is a monkey patch of console.error matched on a substring of someone else's log message.

I am not going to pretend this is elegant. It is a string match against a message from a dependency, which means a Next release that rewords the message turns the filter off silently. The alternatives I considered were worse:

  • Turn the log off. There is no option for it; the optimiser's logging is not configurable.
  • Check the URL before rendering. That is a server round trip per avatar to discover something we will discover anyway when the browser asks.
  • Delete expired avatar URLs on a schedule. Expiry is not visible in the URL, so this means fetching every stored avatar to see which ones still work: a lot of requests to someone else's CDN, every day, to pre-compute a fallback that already costs nothing.
  • Leave the noise in. This was the status quo, and the actual cost of it is that a real error in a list of hundreds of fake ones does not get noticed.

The last one is the argument. A log line that is always there is not information, and it trains you to skim exactly the output you keep around for the day something breaks. Dropping it on a brittle substring match is a trade I will take, and the comment above it is written for the person who has to notice when it stops working.

What generalises

The pattern underneath all five sections is: a resource you do not own can expire without telling you, and every layer has to agree on what that looks like. The component shows the fallback, the hydration path catches the case the event misses, the config stops the cache from outliving the thing it caches, and the log filter stops the handled case from looking like an incident.

If you want the non code version of why a creator sticks around in our database long enough for this to happen at all, how Nakodo works has a section on what is kept and for how long, and the privacy notice lists it field by field.