503, not 404, when an upstream read fails
Send 404 only when the thing truly does not exist. Send 503 when the server could not find out. On a Shopify storefront I worked on, one product-image endpoint sent 404 for both. Shoppers saw no difference. Monitoring would: during an upstream outage, the error dashboards would show clients asking for missing products, not an incident.
The fix took under 11 hours from the issue to the merge, in August 2026: 6 files, 135 lines added and 32 removed. The client is anonymized; the numbers come from the commits, the tests and the issue.
How one null turned an outage into a stream of 404s
The endpoint came out of the work in When the search index disagrees with the source of truth. Product cards stopped reading their images from a search index and read them from Shopify instead. Most cards get those images on the server. One surface, the search overlay, builds its cards in the browser, so it calls a small JSON endpoint per product.
Behind that endpoint, one function reads the product from the Storefront API and
resolves an image list for each colourway. Its contract said “never throws”. Every
failure returned null:
export async function fetchProductCardImages(
storefront: CardImagesReader,
handle: string,
): Promise<Record<string, string[]> | null> {
if (!handle) return null;
try {
const {product} = await storefront.query(PRODUCT_CARD_IMAGES_QUERY, {
variables: {handle},
});
if (!product) return null;
return resolveCardImagesByColour(toCardImageSource(product));
} catch {
return null;
}
}
There are three outcomes here and two of them share one value. A null from the
if (!product) line means the product does not exist. A null from the catch
means the read failed: a network error, a timeout, a malformed response. The catch
also throws the cause away. The route had only one null to look at, so it sent
404 for both.
Why the first version shipped knowing it was wrong
This was not an accident found later. The design for the image work specified both
rules: return null on any failure, and send 404 when the product does not exist.
Read together, they make every outage a 404. The issue that recorded this was opened
the same day the endpoint was written, and it said why it was left: changing the
return type touched two modules that had already been reviewed, and the image fix was
a priority-one bug. Ship it, then follow up.
One mitigation went in first. The 404 got Cache-Control: no-store, 7 minutes after
the endpoint itself was committed. The commit message says why: the 404 “may be
failure-shaped rather than a genuine not-found”, so a CDN must never cache it.
That ordering is right. Shopper impact was nil either way, because the browser treats any non-OK response as “leave the card with the index’s image”. A cached false 404 would have been a real fault: a card held on the index’s stale image for as long as the cache kept the 404, long after the upstream came back. A false 404 that nothing caches is only a monitoring fault, and that can wait half a day.
A result type with three outcomes instead of one null
The follow-up gives each outcome its own shape:
export type CardImagesResult =
| {status: 'ok'; imagesByColour: Record<string, string[]>}
| {status: 'not-found'}
| {status: 'unavailable'; error: unknown};
The function still never throws. A failed read now comes back as unavailable and
carries its cause, so a caller can log the incident without a try of its own. The
test for that is one line of intent: “the whole point of the union: the catch no
longer discards the cause.”
The function has two callers, and they want different things. The server path that
adds images to search results treats not-found and unavailable the same way: the
record keeps the index’s gallery. A comment in that caller says so, and says why: this
path has nothing useful to do with the difference. That is correct. A result type does
not force every caller to care about every case. It only stops one caller from deciding
for all of them.
Which status code each failure should send
The route is the caller that cares, because its response is read by machines:
if (result.status === 'unavailable') {
// A failed read is an incident, not a client error.
context.log.error('Card image read failed', {error: result.error, handle});
throw new Response('Service Unavailable', {
status: 503,
headers: {'Cache-Control': 'no-store'},
});
}
if (result.status === 'not-found') {
throw new Response('Not Found', {
status: 404,
headers: {'Cache-Control': 'no-store'},
});
}
The route’s test file went from 9 cases to 12. The three new cases are the rule: a
failed read sends 503, the 503 is no-store too, and the cause reaches the log. A
later commit, 5 minutes after the fix, made the log test also assert the 503. Before
that it only checked that the loader threw, which a 404 would also pass.
The log test carries a comment I agree with: the status code alone only fixes the error-rate dashboards. A 503 tells monitoring that something is wrong. The structured log, with the error and the product handle, tells the person on call what.
Why a 404 is a claim that a cache can repeat
A status code is not only a message to the client. Every cache between the server and the client reads it too. RFC 9110 names 12 status codes as heuristically cacheable, which means a cache may store the response with no explicit freshness header. 404 is on that list. 503 is not.
So a 404 with no Cache-Control is a claim that a CDN is allowed to repeat: “this
product does not exist”, served from the edge for as long as the cache’s own heuristic
says, long after the upstream has recovered. A 503 with no headers is a claim that a
cache should not repeat on its own. The default behaviour of each code matches what it
means.
This endpoint does not depend on those defaults. Both error responses send no-store.
The success response is the cacheable one: at the time, a day fresh and a week of
stale-while-revalidate (86,400 and 604,800 seconds), later cut to 10 minutes fresh. The browser does its part too. It
keeps a per-page memo of image lists, and it writes to that memo only on a successful
response, so a 503 is not remembered and the next search tries again.
What the 503 still does not do
The 503 carries no Retry-After header. The only client is the storefront’s own
browser code, which retries on the next search anyway, so a retry hint would have no
reader. A public API should send one.
The change also left the context document behind. The repository keeps one file of
domain language, and the same night it got a five-line entry: the three outcomes, and
“404s a missing product and 503s a failed read, both no-store.” The next person to
touch the route reads the rule before they read the code.
The rule I take from it is short. A 404 is a statement about the resource. A 503 is a
statement about the server. When a function returns one null for both, the caller
cannot send the true one, so give the failure its own outcome before anyone has to
choose a status code.
Revisions
- Created from the storefront repository's commits, tests, issue and context document (anonymized), read that day.
- Published on its Oct 30 slot; auto-drafted by the publishing run because no draft existed for the row.