Cates Works
All notes
Reliability/ 6 min read

The empty account that loaded forever

August 6, 2026

We shipped the first production deploy of a multi-brand SaaS — three backend services, four frontends, each brand its own tenant with its own auth database. Green builds across the board, health checks passing, authenticated routes returning real data. Textbook. Then the first human signed up, and the dashboard just… spun. Forever. Nothing was down and nothing had failed; the product had simply reached a state on day one that no screen was designed to handle — empty.

Every request returned 200

The signup worked. Auth worked. The account was even seeded with a few sample customers, so there was real data behind the session. And the calendar page — the page the product opens on — rendered a loading skeleton and never stopped.

There was no failed request to find, no error boundary tripped, nothing interesting in the logs. From the machine’s point of view the page was healthy. From the user’s point of view the product had never started. That gap is the whole story: uptime monitoring answers “is the server responding,” not “can a new customer get past the front door.”

Two lines of code that can never agree

The code, paraphrased:

  • // auto-select the first location, once locations load — if (!selectedLocation && locations.length > 0) select(locations[0])
  • // ...render gate — if (locationsLoading || !selectedLocation) return <Skeleton/>

Read those two lines together with zero locations. The auto-select never fires, because there is no locations[0]. So selectedLocation stays empty. So the render gate stays true. Forever. The skeleton is permanent, and it is permanent for a reason that will never resolve on its own — no retry, no timeout, no amount of waiting changes it.

There was no “you have no locations yet” branch anywhere. The empty set fell through the gap between “still loading” and “ready,” and the code’s default for anything in that gap was “loading.” An empty list got treated as a slow one.

Why the account was empty in the first place

The signup seeder for this brand created sample customers but no location — and there was no UI anywhere in the product to add one. A brand-new account was therefore a reachable, permanent dead end, and it was the very first thing a real customer would see.

None of that is exotic. A fresh account, a list with nothing in it, a selection that never gets made: this is the most predictable state a product has, and it is the one that gets built last, because everyone developing the thing has a database full of test data by week two.

The sibling page that degraded gracefully

One detail is worth keeping, because it explains why only one page broke. On this stack’s data-fetching library, a disabled query reports itself as “not loading.” A sibling page gated on disabled queries, so with no data it degraded politely to a screen full of zeros.

That difference is the entire bug. The graceful page treated “no data” as a real, renderable state and drew it. The broken page treated the absence of a selection as evidence that work was still in progress. Same account, same empty database, two completely different outcomes — decided by which page had an opinion about emptiness.

Two fixes, two horizons

  • Right now, for the one real user: seed their account into a usable shape through the same admin API a real user would hit — one location, a staff member, a couple of services, a few appointments — so the calendar had something to select and render. A hot-fix for a person, not for a codebase.
  • For everyone after them: make the empty set a first-class state. Split the render gate into three — loading shows the skeleton, loaded-but-empty shows a real “no locations yet” card, and only the brief auto-select window keeps the skeleton. Then make the seeder create a starter location so a fresh account is usable out of the box. Merged and deployed the same day.

The boring bugs that never reached a user

Getting to that first signup took the usual deploy scars, and they are worth naming because they repeat — and because they are exactly the class of problem a pipeline is good at catching:

  • Don’t “improve” a database URL you were told not to touch. The connection code opened a bare pool with no TLS option. Helpfully appending sslmode=require would have turned a working connection into a self-signed-cert failure that kills the migration step at build time. The instruction to leave it alone wasn’t superstition; it was load-bearing.
  • Encrypted config that reads back as a blob. One platform stored some frontend config as encrypted env vars, and decrypting them returned a ~1100-character ciphertext blob rather than the value. Feed that to the client as your API URL and every page 500s with “invalid URL.” The fix is boring and mandatory: write the values as plaintext, then read each one back and look at it with your own eyes. Trusting “exit code 0” is how the blob ships.
  • A build guard that assumes git history it doesn’t have. The frontends skip rebuilds when nothing relevant changed, by diffing against the previous deploy’s commit. After a large history jump, that commit wasn’t in the shallow clone the builder checked out, so the diff crashed with “bad object” and every build errored. Guard the SHA’s existence and force a build when it’s unreachable.

Every one of those announced itself — a failed build, a 500, a smoke test that came back wrong. That is the comparison I keep coming back to. Build checks and health checks are very good at catching a broken machine. They are completely blind to a product that is up, green, and unusable for the only person currently using it.

The takeaway

Your tests exercise the states you thought of; your first real customer gets the one you didn’t — the empty one. “It renders with data” and “it renders with no data” are two different features, and the second is the one a brand-new account sees first.

Let’s talk

Have a project, or a product that could work harder?

Most projects begin with a short, no-pressure discovery call.