Engineering Journal
Ginexys
Ginexys

An Upgrade button that might fail is worse than no button

2026-08-21

TLDR: "Not configured", "not signed in", and "already subscribed" are three different answers with three different next actions. Collapsing them into a generic failure on click means someone who just needed to sign in reads a payment error instead.

The position

Before you render a control that starts a payment, ask whether it can succeed. Render the control that matches the answer, not the one you wish were true.

The usual pattern is the opposite: always show Upgrade, attempt on click, catch, display the message. It is less code and it is wrong in the one place where being wrong is expensive.

Why the common pattern exists

Optimistic UI is good advice nearly everywhere. Do not gate the interface on a round trip; assume success, handle the exception. It keeps interfaces fast and code simple, and for most actions the failure is cheap: retry, or the thing simply did not save.

Checkout is not most actions. The user has already decided. Every second and every ambiguous message between that decision and a card form is pure loss, and you do not get a second decision.

Why it fails here

Our checkout endpoint can refuse for three unrelated reasons:

503, no Stripe products yet. The correct UI is the waitlist. This is not an error; it is the state the product is in.

401, signed out. A subscription attaches to an account, so there has to be one. The correct UI is sign-in.

409, already subscribed. Sending them through checkout again opens a second subscription and bills them twice. The correct UI is the billing portal.

Attempt-and-catch renders all three as red text under a button that stays labelled "Upgrade". The signed-out user, who was thirty seconds from paying, reads what looks like their card being declined before they ever saw a card form.

The better rule

Ask first, cheaply, and let the answer choose the control.

GxBilling.available().then(function (state) {
  if (!state.ready) return;         // keep the waitlist, keep sign-in
  renderUpgradeButton();
});

Two details make it work.

The probe must not create anything. Our first version posted a normal checkout request and used the response, which created a real Stripe customer and a real Checkout Session on every page render where an Upgrade button appeared. A button nobody clicks should not leave an abandoned session in the dashboard. So the endpoint takes { probe: true }, runs every gate, and returns the verdict before it touches Stripe.

The probe must run the real gates. A probe that checks something cheaper than what the action checks will disagree with it, and then you have a button that passes its own test and fails on click. Same route, same order, one early return.

What you give up

A round trip before the button appears. Cached, and the answer only changes on events that reload the page anyway.

Simplicity. Four branches instead of one try/catch, and a state object instead of a boolean.

Optimism. You will sometimes show a waitlist to someone who could in fact have paid, if the probe fails open in the wrong direction. Ours fails toward the waitlist, which is recoverable; failing toward a broken payment screen is not.

When attempt-and-catch is right

When the failure modes are few and the recovery is identical. Saving a document, sending a message, applying a filter: it either worked or you retry, and no distinction between causes changes what the user does next.

The test: do the different failure reasons lead to different next actions? If yes, the user needs to know which one before they act, and finding out by clicking is the most expensive way to tell them.

For payment, the reasons always differ. Sign in, wait for launch, manage your existing plan: three different buttons. One of them is not "try again".

Read this post in the full Engineering Journal →