I’m struggling with how Javascript handles rounding numbers, especially with decimals like 1.005 or 2.675 that don’t round the way I expect using toFixed or Math.round. I need help understanding why this happens, what’s going on under the hood with floating point, and what the most reliable approaches are for getting accurate rounded values in real-world apps, like when dealing with prices or financial calculations.
JavaScript is not “bad at math”. It uses binary floating point (IEEE 754 double). That format cannot represent many decimal fractions exactly.
Key point: numbers like 1.005 and 2.675 are stored as nearby binary values.
Example in JS:
Number(1.005).toPrecision(15)
// ‘1.004999999999999’
Number(2.675).toPrecision(15)
// ‘2.674999999999999’
So when you do:
(1.005).toFixed(2)
// ‘1.00’
JS rounds the stored value 1.004999999999999… to two decimals. The nearest is 1.00, not 1.01.
Same for:
(2.675).toFixed(2)
// ‘2.67’
You expect 2.68, but the internal value is a bit less than 2.675, so it rounds down.
This affects Math.round too:
Math.round(1.005 * 100) / 100
// 1
Because 1.005 * 100 is something like 100.4999999999, not 100.5.
Workarounds:
- Use a small offset before rounding
function roundTo2(n) {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
roundTo2(1.005); // 1.01
roundTo2(2.675); // 2.68
Number.EPSILON is about 2.22e-16. It nudges borderline values in the expected direction without large side effects.
- Use integer arithmetic for money
Store cents, not dollars.
let priceCents = 267; // 2.67
let taxCents = Math.round(priceCents * 0.075); // 7.5% tax
Work with integers as long as possible. Convert to string at the end.
- Use a decimal library
If you need exact decimal math, use a library that implements decimal arithmetic, for example:
decimal.js
big.js
bignumber.js
Example with decimal.js (rough idea):
let x = new Decimal(1.005);
x.toDecimalPlaces(2).toString(); // ‘1.01’
- Custom formatting without toFixed quirks
function toFixedSafe(n, decimals) {
const factor = 10 ** decimals;
return (Math.round((n + Number.EPSILON) * factor) / factor)
.toLocaleString(‘en-US’, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
Why this happens at all:
• JS uses binary, not decimal.
• 0.1 has no exact finite binary representation.
• Many decimals end up slightly below or above the value you write.
• Rounding functions operate on the stored binary value, not your literal text.
So your mental model “1.005 is exactly 1.005” does not match the internal representation. Adjusting with EPSILON, using integers for money, or switching to decimal libraries are your practical options.
The “JS is bad at math” meme is really “binary floats are weird with decimals,” as @sonhadordobosque already unpacked nicely. Let me add a slightly different angle and a few alternatives.
First, the mental model fix:
- In JS,
1.005is not stored as exact 1.005. It’s some binary fraction like1.004999999999999… Number.prototype.toFixedandMath.roundare not “decimal” rounders. They’re just rounding whatever binary value is actually there.- So
1.005ends up a bit below 1.005, which is why rounding to 2 decimals gives1.00instead of1.01.
Where I’d push back slightly on the common advice:
- The
+ Number.EPSILONtrick is handy, but it’s a hack. It works for many cases, but it is not mathematically guaranteed to fix every edge case and can surprise you when you scale up or deal with very large/small numbers. Don’t treat it as a universal “make floats correct” button. - For anything involving money or legal/financial reporting, you really should not rely on “let’s nudge the float a tiny bit and hope the rounding aligns with human expectations.”
Some alternative patterns that complement what was already suggested:
- Shift & round with string control
If you just care about display formatting and not deep math:
function roundTo(n, decimals) {
const factor = 10 ** decimals;
// Use toLocaleString purely for formatting, not for rounding
const rounded = Math.round(n * factor) / factor;
// Avoid relying on toFixed's rounding; just formatting the result
return rounded.toLocaleString('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
});
}
roundTo(1.005, 2); // often '1.01' in practice, but test your edge cases
This still uses binary floats, but puts all rounding in one place and keeps toFixed’s quirks out of your main logic.
- Use explicit “bankers rounding” or custom rules
Sometimes what you expect is not actually “round half up” but some business rule. Implement it explicitly, after scaling to an integer:
function bankRound(value, decimals) {
const factor = 10 ** decimals;
const scaled = value * factor;
const floor = Math.floor(scaled);
const diff = scaled - floor;
if (diff > 0.5) return (floor + 1) / factor;
if (diff < 0.5) return floor / factor;
// exactly .5: round to even
return (floor % 2 === 0 ? floor : floor + 1) / factor;
}
Note: because of the same float imprecision, “exactly .5” is still tricky, but at least your rule is explicit and testable.
- Normalize known “troublemaker” values
In specific domains, you sometimes know in advance which decimals are common and problematic. You can normalize them before doing anything else:
function normalize(n, decimals = 3) {
// round to a few more decimals early to kill most noise
const factor = 10 ** decimals;
return Math.round(n * factor) / factor;
}
const n = normalize(1.005, 6); // brings it closer to what you meant
This is still heuristic, but it can reduce surprises for values like 1.005 and 2.675 that keep coming back in your data.
- Hex inspection to see what’s really stored
If you want to understand what is going on and not just patch it, inspect the raw IEEE 754 representation:
function toHexDouble(n) {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setFloat64(0, n);
let hex = ';
for (let i = 0; i < 8; i++) {
const byte = view.getUint8(i).toString(16).padStart(2, '0');
hex += byte;
}
return '0x' + hex;
}
toHexDouble(1.005); // see the exact stored value
Seeing that 1.005 literally isn’t 1.005 at the bit level helps kill the intuition that “JS is randomly failing to round.”
- Separate “calculation layer” and “presentation layer”
A pattern I like:
- Calculation layer: use either integer cents or a decimal library (as already suggested). Never
toFixedhere. - Presentation layer: only convert to human strings at the very end, in one place. All uses of
toFixed,toLocaleString, etc., live here.
This way, any weirdness is constrained to “how my app shows numbers,” not “how it calculates them.” Much easier to test and reason about.
In short, nothing is “wrong” with JS’s Math.round or toFixed. They’re doing exactly what you told them to do on values you didn’t realize were slightly off. The real fix is adjusting your mental model and then choosing the right tool: integers, a decimal library, or a carefully designed rounding/formatting layer instead of sprinkling toFixed everywhere and hoping it behaves.
The “weird rounding” is not a JS bug, it is a mismatch between decimal thinking and binary storage. Others already covered the IEEE 754 side very well, so let me fill in a few gaps and gently disagree on how far tricks like + Number.EPSILON can or should take you.
1. Your mental model needs one tweak
Instead of thinking:
“I wrote
1.005, so JS has exactly 1.005 in memory.”
Switch to:
“I wrote
1.005, JS stores the closest binary fraction, which is usually slightly below or above 1.005.”
All rounding functions operate on that stored binary value, not on the literal you typed.
So:
(1.005).toFixed(2) // '1.00'
is consistent: JS is rounding something like 1.004999999999999… to 2 decimals.
I agree with @shizuka that JS is not bad at math. I slightly disagree with relying on tiny nudges as a long term strategy. They are clever, but they are also heuristics.
2. Why + Number.EPSILON is helpful but not magical
Code like:
function roundTo2(n) {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
often “fixes” cases like 1.005. Pros and cons:
Pros
- Very small change for existing code.
- Fixes common edge cases like
1.005,2.675. - Easy to remember and drop in.
Cons
- Not guaranteed for all magnitudes or all decimal counts.
- Can behave differently when numbers get very large or very tiny.
- Hides the underlying model problem instead of solving it.
For display-only rounding in a UI, I’m fine with it. For anything like accounting, I would not ship a system that depends entirely on that trick.
3. Integers for money: good, but watch conversions
Working in cents (integers) as @sonhadordobosque described is solid practice:
let priceCents = 267; // 2.67
let taxCents = Math.round(priceCents * 0.075);
Two extra notes often missed:
-
0.075is itself an imprecise float. If your tax rate is fixed, prefer storing it as “basis points” or a rational pair:const TAX_NUM = 75; // 7.5 const TAX_DEN = 1000; // 7.5 / 100 const taxCents = Math.round(priceCents * TAX_NUM / TAX_DEN);That limits float trouble to a smaller part of the expression.
-
Do not convert back and forth between dollars and cents repeatedly. Convert once at the boundary, do all logic in integers, then format at the end. The “all integers inside, all formatting at the edge” rule keeps things sane.
4. Decimal libraries: pick based on your needs
If you want exact decimal arithmetic, a dedicated decimal library is the proper fix. You mentioned decimal.js, big.js and bignumber.js. Generic pros & cons:
Pros
- Correct decimal rounding according to specified rules.
- Exact representation of values like
1.005. - Configurable precision and rounding modes (round half up, bankers rounding, etc.).
Cons
- Heavier dependency than “just Numbers”.
- Performance cost in tight loops compared to native floats.
- API friction: you usually have to wrap/unwrap numbers instead of using operators directly.
Competitors like @shizuka and @sonhadordobosque tend to prefer simple float workarounds or conceptual explanations, which are great starting points. For serious financial or legal computations, a decimal library plus an integer strategy is what survives audits.
5. A more explicit rounding pipeline
Instead of sprinkling toFixed and Math.round everywhere, centralize your rounding rules. For example:
const Rounding = {
scale(value, decimals) {
return value * 10 ** decimals;
},
roundHalfUp(value) {
return value >= 0
? Math.floor(value + 0.5)
: Math.ceil(value - 0.5);
},
toDecimals(value, decimals) {
const scaled = this.scale(value, decimals);
const roundedInt = this.roundHalfUp(scaled);
return roundedInt / 10 ** decimals;
}
};
// usage
Rounding.toDecimals(1.005, 2); // often 1.01, but now your rules are visible
This still sits on top of binary floats, but:
- All rounding rules live in one place.
- You can add different strategies (bankers rounding, floor, ceiling) clearly.
- You can later swap the internals to a decimal library without touching the rest of your code.
6. Formatting vs calculating
toFixed is a string formatting function that also rounds. I would treat it only as formatting:
-
Do primary math and rounding using integers or a decimal library.
-
Convert to strings at the very end with something like:
function formatMoney(amount, decimals = 2) { return amount.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); }
That separation avoids the most confusing bugs: you are not relying on a formatting function to perform business logic.
Summing up:
- The heart of the issue is binary floating point, not a JS bug.
- Small nudges (
Number.EPSILON) andtoFixedare fine for casual display, but they are not a full solution. - For anything nontrivial, move to integers or a decimal library, centralize your rounding rules, and keep calculation and presentation separate.