The Empty Catch That Cost Us Half Our Users

57.8% of users who chose a deploy target never finished. There were no error reports — the error dashboard was clean because of the bug, not despite it.

Edgar Durand· Founder, CodeAgent Mobile··4 min read

Our deploy wizard had a conversion problem. Users would pick a repository, choose where to run it, and then — nothing. They left.

The funnel was unambiguous: 57.8% of everyone who selected a deploy target never completed a deploy. 126 people out of 218. For a step that is three clicks from finished, that is not friction. That is a wall.

What made it hard to find is that there was nothing to find. No error events. No exceptions. No support tickets. The analytics showed people arriving at a step and then simply ceasing to exist. We assumed it was a UX problem for months.

It was three lines of code:

.catch(() => {})

The anatomy of a silent failure

The wizard loads its data in stages: repositories, then branches, then machine sizes. Each loader looked like this.

deployApi
  .listMachines(repo, branch)
  .then((m) => setMachines(m))
  .catch(() => {})
  .finally(() => setLoading(false))

That empty catch is doing something specific and terrible. When the call fails — a network blip, an upstream API hiccup, an expired token — the error is swallowed. machines stays []. loading flips to false. The UI renders its "loaded" state with nothing in it.

Then the next thing happens, and this is the part that turns a small bug into a wall:

{step === 'review' && reviewReady && <SectionReview ... />}

reviewReady requires a repository, a branch, and a machine. With no machine, it is false, and the whole review step renders nothing. Not an error. Not an empty state. A blank area of page where the deploy button should be.

So the user's experience is: choose a repo, choose a branch, arrive at the final step, and stare at empty space. There is no message to read, no button to press, and no indication that anything went wrong. Most people conclude the product is broken and leave. A few reload and try again. Nobody files a bug, because there is nothing to describe.

Why the analytics were blind by construction

Here is the property that made this survive so long: the code was written in a way that guaranteed it could not be observed.

We had good instrumentation. We fired an event when a user viewed a step, selected a plane, chose a repository, submitted a deploy. What we never fired was an event when a step failed to load — because from the code's point of view, nothing failed. The catch handled it. The promise resolved. The component rendered.

An empty catch does not just hide the error from the user. It hides it from you. It converts a failure into a successful render of the wrong thing, and successful renders do not get logged.

This is why "we have no error reports for that flow" is such a dangerous sentence. It means one of two things, and they are opposites: either the flow works, or the flow fails in a way you decided not to look at.

The fix, in three parts

Surface the failure. Every loader now records the error, sets an explicit state, and emits an event.

.catch(() => {
  if (cancelled) return
  setLoadError({ what: 'machines' })
  posthog.capture('deploy_step_load_failed', { step: 'machine' })
})

The event matters as much as the UI. Even if we had shipped only the analytics and no visible change, we would have known within a day.

Never render nothing. The blank review step is now an explicit state that says which piece is missing and offers a way back and a retry. A user who hits it can act. Just as importantly, a user who hits it can tell you what they saw.

Say something true about the cause. The message is "Couldn't load your machine sizes — this is usually a temporary hiccup, not a problem with your account," with a working retry. Not because it is friendlier, but because it is accurate: the most common cause is upstream and transient, and blaming the user's account sends them down a support path that leads nowhere.

The regression test is the interesting part

The obvious test is "the error state renders when loading fails." We wrote that. But the test that actually protects the fix asserts something subtler:

it('says what is missing and offers both a way back and a retry', () => {
  const panel = getByTestId('deploy-blocked')
  // The old behaviour rendered an empty fragment — assert there is content.
  expect(panel.textContent?.trim().length).toBeGreaterThan(20)
})

That assertion — this element contains a non-trivial amount of text — looks crude. It is the one that fails if somebody reintroduces a conditional render that collapses to nothing. Testing for the presence of an error message only catches the case you thought of. Testing that the screen is never empty catches the class.

A rule worth adopting

Ban the empty catch. Not as a style preference — as a reliability rule, enforceable by a linter.

.catch(() => {}) is almost never what anyone means. What people mean is "I do not want this to crash the app," and that is achievable while still recording what happened. The empty version says something much stronger: this failure is not worth knowing about. That is a claim you are making about every future occurrence, in every environment, forever.

If a failure genuinely does not matter, write down why:

.catch(() => {
  // Best-effort presence ping; a miss self-heals on the next heartbeat.
})

Now the next person can evaluate the claim instead of inheriting it.

The version of this bug we shipped cost us more than half of everyone who reached the last step of our most important flow, for months, with a completely clean error dashboard. The error dashboard was clean because of the bug. That is the part worth remembering: your monitoring can only show you failures your code agreed to report.

← All posts