Published Build3 min read
One rounding boundary, not five: a payroll calculator's near-miss with floating point
A developer caught a raise calculator returning 53,999.999999999996. The ugly digits were the symptom; two tools disagreeing on the same salary was the defect.
Written for builders.See today for builders

What happened
- Saurabh Sharma, writing on dev.to, describes building PayTimeHub, a set of free payroll and pay calculators including paycheck withholding, a raise calculator, hours-to-decimal and time and a half.
- In a JavaScript console, 0.1 + 0.2 evaluates to 0.30000000000000004.
- The post states that a $52,000 salary with a 3.85% raise should read as a clean $54,000, but that depending on how the multiplication and division are chained the result can come out as something like $53,999.999999999996, which then gets formatted and displayed as a wrong number.
- The post says the worse outcome than a wrong displayed number is that the value rounds inconsistently between two calculators that are supposed to agree with each other.
- The fix was a single shared rounding function that every calculator routes its output through before display: export function roundCurrency(amount: number): number { return Math.round((amount + Number.EPSILON) * 100) / 100; }
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A developer building PayTimeHub, a set of free payroll and pay calculators, published a writeup of a rounding bug he caught before shipping: a raise calculation that should have read as a clean $54,000 could come back as 53,999.999999999996 depending on how the multiplication and division were chained [1][3]. He notes the worse outcome is not the ugly digits but the value rounding inconsistently between two calculators that are supposed to agree with each other [4], and that is the part worth stealing, because a user who checks one of your tools against another and gets two answers has no way to tell which one is wrong.
The mechanics are the boring, well-documented ones. Money arithmetic in JavaScript runs through floating point, which is why 0.1 + 0.2 evaluates to 0.30000000000000004 [2]. The fix was not exotic: one exported function, roundCurrency, returning Math.round((amount + Number.EPSILON) * 100) / 100, which every calculator on the site now routes its output through instead of each one calling .toFixed(2) inline [5][9]. The author's stated reason is the right one: partly consistency, but mostly so the bug had only one place it could hide [9].
The distinction that makes this generalise is that rounding to cents is not formatting. Formatting is a per-view decision about commas and currency symbols. Rounding is a decision about what the number is, and if five calculators each make it in their own template, you have five boundaries that can drift apart under maintenance. Centralising it does not make the arithmetic more correct; it makes disagreement between tools structurally impossible rather than a thing you hope integration tests catch.
Two caveats on the specific implementation. The author says the Number.EPSILON nudge matters because the multiplication by 100 can itself push a value just below a rounding boundary, so something that should round up rounds down, and that adding the smallest possible float corrects those cases without moving any value that was not already on the boundary [6][7]. The post does not name an input where plain Math.round(amount * 100) / 100 returns the wrong cent [14], and the two illustrations in the piece sit at very different scales: the residual error in the 0.1 + 0.2 case is on the order of 4e-17, while the error in the salary case is on the order of 4e-12, roughly a factor of 100,000 apart [13]. Since Number.EPSILON is a fixed absolute constant, that is a reason to pin the behaviour with a test at salary magnitudes rather than take the nudge on faith. The post also does not say which formatter turns the rounded number into display text [15], and truncation versus rounding at that last step is its own class of off-by-a-cent.
The worked example carries a second lesson the post does not draw. A 3.85% raise on $52,000 is $54,002, not $54,000 [11]; the raise that lands exactly on $54,000 is 3.846% [12]. The clean figure in the example only appears if you feed the calculator a percentage that was already rounded for display.
Things to watch if you are doing this in your own codebase: whether the shared function is enforced mechanically, say a lint rule banning .toFixed in view code, rather than by habit; whether rounding happens once at the display boundary or repeatedly mid-chain, which reintroduces disagreement by a different route; and whether any two of your calculators, given the same inputs, actually produce the same cents.
The author says the bug class is invisible in casual testing, because round numbers like $50,000 and 5% never trigger it [8]. That is the argument for owning one boundary. You cannot test your way to consistency across five of them.
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
Saurabh Sharma, writing on dev.to, describes building PayTimeHub, a set of free payroll and pay calculators including paycheck withholding, a raise calculator, hours-to-decimal and time and a half.
- [2]
In a JavaScript console, 0.1 + 0.2 evaluates to 0.30000000000000004.
- [3]
The post states that a $52,000 salary with a 3.85% raise should read as a clean $54,000, but that depending on how the multiplication and division are chained the result can come out as something like $53,999.999999999996, which then gets formatted and displayed as a wrong number.
- [4]
The post says the worse outcome than a wrong displayed number is that the value rounds inconsistently between two calculators that are supposed to agree with each other.
- [5]
The fix was a single shared rounding function that every calculator routes its output through before display: export function roundCurrency(amount: number): number { return Math.round((amount + Number.EPSILON) * 100) / 100; }
- [6]
The author states that without the Number.EPSILON addition, plain Math.round(amount * 100) / 100 still misfires on specific inputs, because the multiplication itself can introduce a floating-point error just below a rounding boundary, so a value that should round up rounds down instead.
Sources & coverage · 1 publisher
The reporting this story was synthesized from, earliest first. Every link goes to the original.
- dev.toSaurabh SharmaAug 14The rounding bug that almost shipped in a payroll calculator
Cited in this coverage: Saurabh Sharma, dev.to
Cited in this coverage: absence in the dev.to post

