The bug that is not a float bug
Every engineer who has worked near payments knows the demo. Open a console, type 0.1 + 0.2, and watch JavaScript return 0.30000000000000004. The lesson lands immediately: do not store money in floating point. Use integer cents. Most teams learn this once, fix it everywhere, and consider the money problem solved.
It is a real fix. It is also the smallest of the problems, and fixing it tends to create a false sense of completion.
Here is a bug I watched take three days to find. A customer's invoice total was off by one cent, but only for invoices with three or more line items, and only sometimes. The codebase was disciplined. Everything was integer cents. There was not a float anywhere in the money path. The arithmetic was correct at every step, and the total was still wrong.
The problem was not the arithmetic. The problem was that the code had computed a per-item tax by multiplying and dividing, rounded each result independently, and then summed the rounded values. Rounding six times and adding is not the same operation as adding and rounding once. Both are defensible. They produce different numbers. Nobody had decided which one the business meant, so the code had picked one by accident, in a helper function written eight months earlier for a different purpose.
Integer cents protected the representation. Nothing protected the meaning.
“Integer cents fix how money is stored. They do not fix what your code believes money is.”
What a balance actually is
Ask most frontends what a balance is and the honest answer is: a number that came back from an endpoint. That is the root of a surprising amount of trouble.
A money value carries at least three things. The amount, which is the part everyone models. The currency, which teams usually model until the day the product goes multi-currency and it turns out half the codebase assumed one. And the moment, which almost nobody models, and which causes the worst bugs of the three.
The moment matters because no account has a balance sitting in a column somewhere. A balance gets computed, by replaying every event against that account up to a point in time. The number you fetched at 10:42 was true at 10:42. By the time it renders, a payment may have cleared, a hold may have dropped, a reversal may have posted. The number on screen is a photograph, and your UI is presenting it as a live feed.
// The shape most frontends end up with
type Balance = number;
// The shape that survives contact with a ledger
type Money = {
/** Minor units. 1250 means $12.50, never 12.5. */
amountMinor: bigint;
currency: 'USD' | 'EUR' | 'GBP';
};
type Balance = {
available: Money;
pending: Money;
/** The ledger position this was computed from. */
asOf: string;
};That asOf field looks like bookkeeping overhead until the first time support asks why a customer saw one number and the statement shows another. With it, the answer takes a minute. Without it, the answer is a guess, and the guess is usually "cache."
The multi-currency point deserves more than a shrug. amountMinor alone is not money, it is an integer. The moment two currencies exist in the same system, every function that accepts a bare number becomes a place where dollars and euros can be added together without complaint. The type system will not stop you, because both are number. Pairing the amount with its currency in one indivisible value is what makes that mistake impossible to express.
The remainder has to go somewhere
Split ten dollars three ways. Each person gets $3.33, and one cent is left over.
That cent is not a rounding error. It exists, it belongs to someone, and it has to end up somewhere. Code that computes total / 3 and rounds has silently decided that the cent belongs to nobody, which means the parts no longer sum to the whole. In a ledger, parts that fail to sum to the whole are the definition of a broken book.
The fix is to allocate rather than divide. Compute the floor for each share, count what is left, and distribute the leftover deliberately.
/**
* Splits an amount across weights so the parts always sum to the total.
* Leftover minor units go to the earliest recipients, which is arbitrary
* but consistent, and consistency is the property that matters.
*/
function allocate(totalMinor: bigint, weights: bigint[]): bigint[] {
const totalWeight = weights.reduce((sum, w) => sum + w, 0n);
const shares = weights.map((w) => (totalMinor * w) / totalWeight);
let remainder = totalMinor - shares.reduce((sum, s) => sum + s, 0n);
for (let i = 0; remainder > 0n; i = (i + 1) % shares.length) {
shares[i] += 1n;
remainder -= 1n;
}
return shares;
}
allocate(1000n, [1n, 1n, 1n]); // [334n, 333n, 333n], sums to 1000nWhether the extra cent should go to the first recipient, the largest recipient, or the house is a business question with a real answer that differs by product and sometimes by jurisdiction. The engineering requirement is narrower and non-negotiable: somebody has to get it, the choice has to be written down, and the parts have to sum to the whole every time.
“Splitting money always leaves a remainder. Code that does not decide where it goes has decided it goes nowhere, and the books stop balancing.”
Optimistic UI is a promise you cannot keep
Optimistic updates are one of the better ideas in frontend. Apply the change locally, fire the request, reconcile when it returns. The interface feels instant, and if the request fails you roll back and apologize.
That trade works because in most products the rollback is cheap. The like count was wrong for four hundred milliseconds. Nobody was harmed.
Money breaks the trade in a specific way. When a user sees their balance drop by $40 and a payment marked complete, they do not read that as a prediction. They read it as a fact about the world, and they act on it. They close the laptop. They tell the person they were paying that it went through. If the request then fails, you are not rolling back a rendering. You are contradicting something a person already believed and acted on, and every subsequent number you show them is a little less trustworthy.
The failure I have seen most often does not involve a failed request at all. The request succeeded, slowly, landing in a state the optimistic model had no vocabulary for. The payment was accepted for processing, and would settle in two days. The frontend knew two states, before and after, so it picked after. The user saw "Paid." Two days later a hold expired and the money reappeared, and support fielded a call about a payment that had reversed itself.
The honest interface is not slower. It just declines to collapse a three-state world into two.
// Collapses a real state machine into a boolean
{isPaid ? <Badge>Paid</Badge> : <Badge>Unpaid</Badge>}
// Renders what is actually known
{payment.status === 'submitted' && (
<Badge tone="neutral">Sent, awaiting confirmation</Badge>
)}
{payment.status === 'settled' && (
<Badge tone="positive">Paid {formatDate(payment.settledAt)}</Badge>
)}
{payment.status === 'returned' && (
<Badge tone="critical">Returned by bank</Badge>
)}Users tolerate pending far better than they tolerate a number that changes its mind. What destroys confidence is not latency. It is a product that told them one thing and then told them another.
“Users forgive pending. They do not forgive a balance that changes its mind.”
The retry that pays twice
A user taps Send. The network stalls. They tap it again.
In most products this is a debounce bug worth a low-priority ticket. In payments it is two transfers, and getting the second one back involves a reversal, a support queue, and a customer who now checks every statement line by hand.
The instinct is to disable the button, and disabling the button is correct, but it is not sufficient and it is worth being precise about why. Disabling handles the second tap. It does not handle the request the browser retried on a flaky connection, or the tab restored from history that replays a submission, or the mobile client resuming after the radio dropped. Those retries never touch your click handler.
The mechanism that actually works is an idempotency key: a unique identifier for the intent, generated once, sent with every attempt at that intent. The server records the key against the result. A second request carrying a key it has already seen returns the original result instead of performing the operation again.
The important detail, and the one most implementations get wrong, is where the key is generated.
// Wrong: a new key per attempt, which makes every retry a fresh payment
async function submitPayment(payment: PaymentIntent) {
return api.post('/payments', payment, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
});
}
// Right: the key belongs to the intent, so every retry carries the same one
function usePaymentSubmission(payment: PaymentIntent) {
// Generated when the user forms the intent, not when a request is sent.
const idempotencyKey = useMemo(() => crypto.randomUUID(), [payment.id]);
return useCallback(
() =>
api.post('/payments', payment, {
headers: { 'Idempotency-Key': idempotencyKey },
}),
[payment, idempotencyKey]
);
}Generated per request, the key is decoration. Every retry looks like a new payment, which is exactly the thing you were trying to prevent. Generated per intent, it is a guarantee that survives retries the frontend never sees.
If a request times out, the operation may still have succeeded. A timeout tells you nothing about the server's state. Without an idempotency key you have no safe move: retrying risks a double payment, and not retrying risks a payment the user believes failed. The key is what turns that dilemma into a retry.
Rendering uncertainty honestly
The thread connecting all of this is that a fintech frontend is in the business of representing a system it does not control and cannot see directly. The ledger is the truth. The API is a description of the ledger at a moment. The UI is a rendering of that description, some milliseconds later, on a device that may have been offline since Tuesday.
Every layer adds distance from the truth, and the interface is where the accumulated distance either gets acknowledged or gets hidden behind a confident-looking number.
Hiding it is the default, because hiding it looks cleaner. One number, right-aligned, two decimal places. No qualifiers. Design reviews reward it and users find it calming, right up until it is wrong, and the discovery that it can be wrong retroactively undermines every number the product has ever shown them.
The teams that get this right treat precision about uncertainty as a feature rather than clutter. They show the pending amount next to the available one. They timestamp the balance. They say "sent" when the money has been sent and "settled" when it has settled, and they resist every request to compress those into "done" because compressing them is what creates the support call.
None of this is exotic engineering. It is mostly the discipline to keep modeling the domain accurately after the point where a simpler model would still compile.
Money is not a number. It is a claim about a moment, made by a system you do not own, rendered by a UI a user will trust more than you intended. Model it that way and most of the classic fintech bugs never get written.
“The ledger is the truth. Everything your user sees is a rendering of a description of it, and the interface is where that distance gets acknowledged or hidden.”