For months, a dropshipping SaaS platform I helped build was losing sales in a way no chart ever showed. The buyer reached checkout, the backend created the PIX charge and answered 200, the order existed in the database — and for a share of buyers, the payment screen simply vanished before the QR Code appeared. Nobody on the team saw a thing. The log was clean.
This post is about the gap where that kind of error lives: the space between the server's last log line and what the user actually experienced on screen. That is where the entire frontend happens — and where, by default, you are blind. The eventual fix was cheap. The blindness was expensive.
The symptom: 200 in the log, blank screen in the browser
The flow was that of any PIX checkout. The buyer completed the order, the Next.js frontend called the Node API, the API requested the charge from the gateway and returned the PIX data. The screen rendered the QR Code and the copy-and-paste code. On the server side, that path had an essentially perfect success rate.
On the buyer's side, it did not. In a fraction of charges, the payment screen disappeared: no QR Code, no code, no error message. A blank area where the only thing that mattered at that moment should have been.
[info] order.created orderId=7f3a91 sellerId=442 total=189.90
[info] charge.requested orderId=7f3a91 gateway=pix
[info] charge.created orderId=7f3a91 status=pending
[info] http POST /orders 201 148msReading that log, there is no incident. There is an order created, a charge generated and a response delivered. Any alert based on HTTP status, server error rate or backend exception would stay silent forever — because from the backend's point of view, nothing failed.
How we found out: a customer told us
It was not an alert. It was a seller on the platform reporting that one of their buyers could not pay. That is the worst possible detection channel, and it is worth facing why.
People who complain are a self-selected minority. A buyer who opens checkout, sees a blank screen and leaves does not file a ticket — they give up, and maybe buy somewhere else. For the report to reach you, someone has to be motivated enough, have an open channel, and have the patience to describe the problem. Every complaint that arrives stands for an unknown — and larger — number of silent abandonments.
When your detection channel is the annoyed user, you are not measuring failures. You are measuring persistence.
Why server logs were never going to catch this
The short answer: the error was born after the last point we observed. The whole request had already finished successfully by the time the problem happened.
- Observed: the order is created and persisted — 201.
- Observed: the charge is requested from the gateway and comes back without error — 200.
- Observed: the response leaves the API and is delivered to the browser — 200.
- Blind: the client deserializes the payload and builds the screen state.
- Blind: the QR Code component renders — and throws.
- Blind: the component tree collapses and the payment screen disappears.
- Blind: the buyer does not pay, closes the tab and vanishes from your funnel.
Half of that list is frontend territory, and not one line of it existed anywhere. Backend observability answers “did my system respond?”. Frontend observability answers “did the user succeed?”. Those are different questions, and the second one is the one that pays the bills.
The root cause: a 200 with an incomplete payload
Under certain conditions the gateway answered successfully but without the PIX code field — the charge existed on their side, the status came back coherent, only the data the frontend needed to draw the screen was missing. The API forwarded the response without validating what it was forwarding. And the component trusted it.
// Original version: assumes the payload always carries the PIX code
export function PixQrCode({ charge }: { charge: Charge }) {
return (
<div className="checkout-pix">
<img
src={`data:image/png;base64,${charge.qrCodeBase64}`}
alt="PIX QR Code"
/>
<code>{charge.qrCodePayload.toUpperCase()}</code>
</div>
)
}charge.qrCodePayload arrived as undefined, .toUpperCase() threw a TypeError, and React did exactly what it promises: it tore down the tree from that point. With no error boundary isolating checkout, what fell was not the QR Code — it was the entire payment screen.
That also explains the “only for some buyers”, which is the property that makes this bug so hard to see. It was not a deterministic error that would show up in the first test. It was a varying third-party response hitting a subset of charges that nobody could reproduce on demand.
What the instrumentation revealed
Sentry came in after the report, not before — and that is the honest part of the story. We could have shipped the one-off fix from the seller's report alone. What we could not answer were the questions that came next: how many buyers does this hit? since when? only on this gateway? has it stopped?
Capturing the exception on its own answers none of that. A TypeError without context is one stack trace line in a minified file. What turns an error into something actionable is what travels with it:
// The context that turns "an error" into "an error you can reproduce"
Sentry.setUser({ id: seller.id }) // who — no email, no name
Sentry.setTag("checkout.gateway", "pix")
Sentry.setContext("charge", {
orderId: charge.orderId,
status: charge.status,
hasQrPayload: Boolean(charge.qrCodePayload), // the shape, never the value
hasQrImage: Boolean(charge.qrCodeBase64),
})
Sentry.addBreadcrumb({
category: "checkout",
message: "charge.received",
level: "info",
})The detail that paid off most was recording the shape of the payload, not its content: hasQrPayload: Boolean(charge.qrCodePayload). It is safe for a payment flow — it carries no value at all — and it is exactly what lets you group events and see the distribution. That is where it became clear this was not one buyer with an odd browser: it was a pattern, reproducible statistically even when it was not reproducible by hand.
Source maps in the build close the loop. Without them, you get the error in the right place in the product and the wrong place in the code.
The fix, in two layers
The first layer is defensive, on the client. The frontend stopped assuming the payload arrives complete and started treating a missing PIX code as a possible screen state — with an alternative path, not a blank screen:
export function PixQrCode({ charge }: { charge: Charge }) {
// No payload means no payment is possible: that is a screen state,
// not an exception
if (!charge.qrCodePayload) {
Sentry.captureMessage("checkout.pix.missing_payload", {
level: "error",
extra: { orderId: charge.orderId, status: charge.status },
})
return <PixUnavailable orderId={charge.orderId} />
}
return (
<div className="checkout-pix">
{charge.qrCodeBase64 ? (
<img
src={`data:image/png;base64,${charge.qrCodeBase64}`}
alt="PIX QR Code"
/>
) : (
<QrCodeFromPayload value={charge.qrCodePayload} />
)}
<CopyablePixCode value={charge.qrCodePayload} />
</div>
)
}Note that the fallback is not a generic error message. If the PIX code exists but the image did not arrive, the QR is generated on the client from the payload; and the copy-and-paste code always shows, because on its own it is enough to complete the payment in the banking app. The question that guides a checkout fallback is not “how do we announce the failure”, it is “what can the user still do”.
The second layer is the contract, on the server. A charge the client cannot render is not a successful charge, and it should not leave the API as a 200:
// A charge the client cannot render is not a successful charge
const charge = await this.gateway.createCharge(order)
if (!charge.qrCodePayload) {
this.logger.error(
{ orderId: order.id, gatewayStatus: charge.status },
"charge.incomplete",
)
throw new ServiceUnavailableException("PIX_CHARGE_INCOMPLETE")
}
return toChargeResponse(charge)Together, the two layers change the nature of the problem. The backend stops propagating an impossible state and starts producing a visible error — one that alerting catches. The frontend stops depending on the backend always being right. Neither would be enough alone: server-side validation does not protect against other fields going missing later, and client-side defense without the contract merely swaps a loud error for a silent degradation.
What we deliberately did not instrument
Checkout is the flow where the urge to capture everything is strongest — and where it costs the most. The rules we settled on:
- No payment data in the event. PIX code, amounts and buyer identity stay out. We record the presence of the field, never its value.
- No session replay in checkout. Recording the screen where a user handles payment data means shipping sensitive information to a third party. The debugging gain does not justify the risk.
- Sampling on transactions, not on errors. Performance traces are sampled; an exception in a payment flow is always captured. They are worth different amounts.
- Noise treated as a bug. Browser extension errors,
ResizeObserver loop, third-party network failures that do not affect the user — all filtered out. A dashboard with 400 daily events nobody reads is indistinguishable from having no dashboard at all.
The outcome
Once that path stopped breaking, transacted volume went from swinging around R$ 20k — with part of it leaking on the source marketplaces — to holding between R$ 60k and R$ 70k per month, with the operation reaching roughly 2,000 orders a month.
I will not claim observability produced that number: a product grows for many reasons at once, and attributing all of it to one fix would be dishonest. What can be stated safely is more specific and, in my view, more unsettling — there was a leak at the exact point where money comes in, it lasted as long as it did because nothing in the system was capable of showing it, and the cost of discovering it through a customer report was paid in sales nobody ever counted.
The gap that is still open
What we built answers “the client broke and I know enough to reproduce it”. It does not answer “this browser error corresponds to this server request”. Today I would close that with a correlation ID: the frontend generates an identifier per request, sends it in a header, the backend writes it on every log line for that flow, and the same identifier travels with the client-side error context.
With that, investigating stops being a matter of matching two worlds by approximate timestamp and becomes a key lookup. It is the difference between correlating and guessing — and it is the natural next step for any frontend instrumentation that already captures context.
What I take from this
- Server logs measure your system, not your user. The two diverge exactly at the most expensive place: after the response.
- A frontend error is not a “UI error”. When it lives in checkout, it is lost revenue — and it shows up in the numbers before it shows up on any technical dashboard.
- All external data is untrusted input, including data from your own backend. Rendering a third-party field without validating it is trusting a contract you do not control.
- An error boundary is blast containment. Without isolating the component, one missing field takes down the whole screen instead of degrading a piece of it.
- Context beats volume. One error with user, route, state and payload shape is actionable. A thousand errors without context are a dashboard nobody opens.
