We spent a sprint on checkout performance. Prefetched tokens, warmed the payment sheet, bundle-split the checkout screen. End-to-end timing improved by roughly 600ms, which is a real number and visible in the traces.
Then we retested with users. Not one person mentioned speed. Not in the sessions where it was faster, and not in the earlier ones where it wasn't.
Everything they did mention fell into one category, and it wasn't performance.
- Measurable speed improvements produced zero user comments in moderated retests
- Every drop-off users could articulate was a trust problem, not a latency problem
- Biometric confirmation changed how users described an otherwise identical flow
- Stripe's default decline message reliably reads as "your card is fraud-flagged"
- We now review payment flows against four trust questions before touching perf
This is a follow-up. The earlier findings from this testing (the double-tap problem and the total-mismatch problem) are in the first writeup; this post covers what surfaced afterwards.
Users drop off at trust moments, not slow moments
That's the headline and it took us a sprint of wasted effort to learn.
When someone abandons a checkout, they can usually tell you why, and the reason is almost always about confidence rather than time. Nobody says "that took 900 milliseconds and I was unwilling to wait." They say some version of "I wasn't sure what was happening" or "that didn't feel right."
Latency matters when it creates a trust problem. A tap that produces no visible response for half a second reads as a broken button, and the user taps again. That's a trust failure caused by latency, and fixing the latency fixes it. But shaving 600ms off a flow that already felt responsive changed nothing anyone could perceive.
If you have a fixed budget for a payment flow rebuild, spend it on the four things below before you open a profiler.
Biometrics change how the same flow feels
This one surprised us most.
Users describing the flow with a Face ID confirmation on the final tap used the word "safe." The same users describing the flow without it reached for "kind of sketchy," "is this the real app," and "I don't know if it went through."
Same gateway. Same UI. Same latency. The only delta was a native biometric prompt before presenting the sheet.
import * as LocalAuthentication from 'expo-local-authentication';
async function confirmAndPay() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (hasHardware && isEnrolled) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Confirm your purchase',
fallbackLabel: 'Use passcode',
});
if (!result.success) return;
}
await presentPaymentSheet();
}
Note the graceful degradation. Devices without enrolled biometrics fall through to the sheet rather than blocking the purchase, which matters more than it sounds — gating checkout behind hardware some users don't have is a worse problem than the one you're solving.
The interesting part is that nobody asked for this. No user in any session said "I wish this had Face ID." They just described the version without it as untrustworthy, in language they'd never have produced in response to a survey.
Stripe's default decline copy is actively harmful
"Your card was declined."
Technically accurate. In sessions, users read it as your card has been flagged, assumed something was wrong with their account rather than with this transaction, and several closed the app entirely rather than trying another card.
The fix is mapping decline codes to plain language plus a recovery action:
const DECLINE_COPY: Record<string, { message: string; action: string }> = {
insufficient_funds: {
message: 'Your bank says there are insufficient funds on this card.',
action: 'Try a different card',
},
incorrect_cvc: {
message: "The security code didn't match.",
action: 'Re-enter card details',
},
expired_card: {
message: 'This card has expired.',
action: 'Use a different card',
},
generic_decline: {
message: 'Your bank declined this charge. This is usually temporary.',
action: 'Try a different card',
},
};
Two details that matter more than the copy itself. Attribute the decline to the bank, not to your app, because users who think your app rejected them do not retry. And make the recovery action reopen the sheet with the cart intact, rather than dumping them back at the top of checkout, which is a second abandonment opportunity you built yourself.
Show the real total before the sheet opens
Related to but distinct from the total-mismatch problem in the first writeup: it isn't only that the numbers must match, it's when the user learns the final number.
Fees and taxes computed server-side and revealed inside the payment sheet are technically visible, but by then the user has anchored on the cart price. A higher number appearing at the moment of payment reads as a bait-and-switch even when the delta is trivial. Forty cents is enough.
Compute the full total, including tax and fees, on the cart screen, with a breakdown available on tap. The number the user commits to should be the number they pay.
The four questions we ask now
Before any payment flow work, in this order:
- Is the complete total, including all fees, visible before the sheet opens?
- Is there a biometric confirmation on the final tap, with a sensible fallback?
- Does the tap produce a visible response immediately, ideally by prewarming the sheet?
- Are error states mapped to plain-English copy with a working recovery path?
Visual polish, sheet customisation, and perf tuning all come after these. Not because they don't matter, but because in our testing they didn't move the thing we were trying to move.
If you're scaffolding a new project rather than repairing one, starting from checkout screens that already have the biometric gate, prewarmed sheet, and mapped error states wired in removes most of this before it exists. It's part of what RapidNative generates for Expo projects.
What I'd tell past me
Run the sessions before the sprint, not after.
We built the perf work because it was legible, measurable, and felt like the responsible engineering choice. It was also unfalsifiable in the only way that mattered: we could prove it worked in a trace and never prove it worked on a person.
Twelve moderated sessions cost less than the sprint did.
What's the last checkout change you shipped that moved a real number? I'm collecting the ones that worked, and my suspicion is that almost none of them are performance.
Top comments (0)