How to Format Numbers in JavaScript
To format a number in JavaScript, use Intl.NumberFormat — it handles thousands separators, decimal places, currency, percentages and locale conventions in one call. new Intl.NumberFormat('en-US').format(1234567.891) gives "1,234,567.891".
Two things worth knowing before you use it: toFixed does not round the way you expect — (1.005).toFixed(2) returns "1.00", not "1.01" — and creating a formatter inside a loop is roughly 50 times slower than creating one and reusing it. Both figures below are measured, not quoted.
Number formatting looks trivial until a user in Germany sees 1,234.50 and reads it as one thousand two hundred, or an invoice total is a cent short because of a rounding assumption. This guide covers the three tools JavaScript gives you, when each is right, and the two traps that produce most of the bugs.
Why raw numbers look wrong
JavaScript's default conversion does nothing you would want in an interface:
(1234567.891).toString() // "1234567.891" — no separators
0.1 + 0.2 // 0.30000000000000004
The second line is the famous one. JavaScript numbers are IEEE 754 doubles, storing values in binary, and decimals like 0.1 have no exact binary representation — just as 1/3 has no exact decimal one. The arithmetic is correct to about 15–17 significant digits; the rest is noise you must not show a user.
This is not a JavaScript quirk. The same calculation in Python produces the same artifact, as the unit-converter project in our Python projects for beginners guide demonstrates.
Option 1: toLocaleString, for the quick case
Every number has this method, and for simple display it is the shortest thing that works:
(1234567.891).toLocaleString('en-US') // "1,234,567.891"
(1234567.891).toLocaleString('de-DE') // "1.234.567,891"
(1234567.891).toLocaleString('en-IN') // "12,34,567.891"
All three are real output. Note what changes: German swaps the roles of comma and full stop entirely. Indian English groups by two after the first three digits — the lakh and crore system — which is not something you would ever produce by hand with a regular expression.
This is the argument against writing your own separator function. There are grouping conventions your regex will not anticipate, and the platform already knows all of them.
Called with no locale, it uses the user's own — usually what you want for a general audience, and worth overriding when a number must be unambiguous regardless of who is reading.
Option 2: Intl.NumberFormat, for everything else
Same engine, but you build the formatter once and reuse it. It also unlocks the options that make output look professional.
Currency
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5)
// "$1,234.50"
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5)
// "1.234,50 €"
Note that the symbol moves to the end in German, with a non-breaking space before it. Currency formatting is not "prefix a symbol", and hard-coding '$' + n.toFixed(2) is wrong the moment you have one non-US user.
Percentages
new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 }).format(0.4567)
// "45.7%"
It multiplies by 100 for you. Pass 0.4567, not 45.67 — passing the already-multiplied value is the single most common mistake with this option. If the percentage arithmetic itself is the confusing part, our guide to calculating percentages covers the three formulas, and the percentage calculator checks your working.
Compact notation
const c = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
c.format(1234567) // "1.2M"
c.format(9800) // "9.8K"
Exactly what you want for view counts and dashboard tiles, and it localises properly rather than hard-coding "K" and "M".
Units
new Intl.NumberFormat('en-US', { style: 'unit', unit: 'kilometer', unitDisplay: 'long' })
.format(1.609344)
// "1.609 kilometers"
Useful for any converter interface — it handles pluralisation and unit names per locale. The MDN reference for Intl.NumberFormat lists the full set of supported units.
Trap 1: toFixed does not round how you think
toFixed is the method everyone reaches for first, and it produces the bug reports:
(1.005).toFixed(2) // "1.00" — not "1.01"
(2.675).toFixed(2) // "2.67" — not "2.68"
Both are real output. Nothing is broken: 1.005 cannot be stored exactly, and the nearest double is very slightly below 1.005, so rounding to two places correctly gives 1.00. The number you typed was never the number stored.
It has a second failure that matters for converters. toFixed counts digits after the decimal point, so it destroys small values:
(0.000000621).toFixed(4) // "0.0000"
Use toPrecision when magnitude varies, because it counts significant digits instead:
const tidy = (n, digits = 10) => parseFloat(n.toPrecision(digits)).toString();
tidy(12.000000000000002) // "12"
tidy(0.000000621371192237334) // "6.213711922e-7"
Ten significant digits is comfortably inside a double's reliable precision, so the binary noise disappears while genuine precision survives. Wrapping in parseFloat then toString strips the trailing zeros toPrecision leaves. This is the same helper used in our JavaScript unit converter guide, and the reason it exists.
| Need | Use |
|---|---|
| Display to a user, any locale | Intl.NumberFormat |
| Quick one-off display | toLocaleString |
| Fixed decimal places, known magnitude | toFixed |
| Variable magnitude, kill float noise | toPrecision |
| Money arithmetic | Integer minor units, or BigInt |
Trap 2: constructing formatters in a loop
Building an Intl.NumberFormat is expensive — it resolves locale data. Doing it per row is the performance bug people ship without noticing, because it only hurts at scale.
Formatting 20,000 numbers, measured on Node 22:
// slow — a new formatter every iteration
for (let i = 0; i < 20000; i++) new Intl.NumberFormat('en-US').format(i);
// fast — build once, reuse
const fmt = new Intl.NumberFormat('en-US');
for (let i = 0; i < 20000; i++) fmt.format(i);
| Approach | 20,000 numbers |
|---|---|
| New formatter each call | 362 ms |
| One formatter, reused | 7 ms |
About 50× faster for a one-line change. Note that toLocaleString has exactly this problem invisibly — each call constructs a formatter internally — which is the real reason to prefer Intl.NumberFormat anywhere you format more than a handful of values. Declare formatters at module scope and reuse them.
Money: do not use floats at all
The safest approach to currency is not to format your way out of the problem but to avoid it: store money as an integer number of minor units — cents, pence — and divide only at the moment of display.
const cents = (dollars) => Math.round(dollars * 100);
(cents(0.1) + cents(0.2)) / 100 // 0.3, exactly
Integers are exact in JavaScript up to Number.MAX_SAFE_INTEGER (about 9 quadrillion), which is far beyond any realistic monetary total. For very large values or arbitrary precision, BigInt or a decimal library is the next step.
A practical setup
// module scope — built once
const NUM = new Intl.NumberFormat('en-US');
const MONEY = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const PCT = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 });
const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
export const fmtNumber = (n) => (Number.isFinite(n) ? NUM.format(n) : '—');
export const fmtMoney = (n) => (Number.isFinite(n) ? MONEY.format(n) : '—');
export const fmtPercent = (n) => (Number.isFinite(n) ? PCT.format(n) : '—');
export const fmtCompact = (n) => (Number.isFinite(n) ? COMPACT.format(n) : '—');
The Number.isFinite guard matters: NaN formats as "NaN" and Infinity as "∞", and users should never see either. Returning an em dash for missing data is far better than either.
Frequently asked questions
How do I add commas to a number in JavaScript?
Use toLocaleString() or Intl.NumberFormat: (1234567).toLocaleString('en-US') gives "1,234,567". Avoid writing a regular expression for it — grouping conventions differ by locale, and Indian English groups by two after the first three digits.
Why does toFixed(2) round 1.005 down to 1.00?
Because 1.005 cannot be represented exactly as a binary floating-point number, and the nearest stored value is fractionally below 1.005. Rounding that stored value to two places correctly gives 1.00. For money, work in integer cents and divide only when displaying.
What is the difference between toFixed and toPrecision?
toFixed counts digits after the decimal point, so very small numbers collapse to 0.0000. toPrecision counts significant digits and behaves sensibly across any magnitude, which makes it the right choice whenever the size of the result varies.
Is Intl.NumberFormat slow?
Creating one is relatively expensive; using one is fast. In a measured test, formatting 20,000 numbers took 362 ms when constructing a formatter each time versus 7 ms reusing a single instance. Declare formatters once at module scope.
How do I format currency for different countries?
Pass both a locale and a currency code: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }). The locale controls placement, separators and spacing; the currency code controls the symbol. Do not concatenate a symbol manually — several locales put it after the number.
How do I format a percentage?
Use { style: 'percent' } and pass the decimal fraction, not the percentage: format(0.4567) gives "45.7%" with maximumFractionDigits: 1. Passing 45.67 would give 4,567%.
Conclusion
Intl.NumberFormat for anything a user reads, built once and reused. toPrecision rather than toFixed when magnitude varies. Integer minor units for money.
Those three rules cover essentially every number you will display, and they sidestep both the rounding surprise and the performance trap that catch most people.
Comments