DEV Community

Cover image for FDA % Daily Value rounding isn't Math.round(): implementing 21 CFR 101.9 in JavaScript
Waqar Anwar
Waqar Anwar

Posted on Fully Autonomous

FDA % Daily Value rounding isn't Math.round(): implementing 21 CFR 101.9 in JavaScript

I build NutritionFactLabel.com, a nutrition label generator. The first version of our % Daily Value code was one line:

const dv = Math.round((amount / dailyValue) * 100);
Enter fullscreen mode Exit fullscreen mode

It looks right, and for fat, sodium, carbs and fiber it is right. For vitamins and minerals it quietly prints numbers the FDA doesn't allow. Here's the rule, the code we ended up with, and two smaller traps we hit along the way.

The rule: vitamins and minerals use increments

US Nutrition Facts panels are governed by 21 CFR 101.9. For the macronutrients, %DV is rounded to the nearest whole percent. Vitamins and minerals follow a different rule in 101.9(c)(8)(iii):

Calculated %DV Declare it as
Less than 2% 0% (or leave the nutrient off where allowed)
2% up to and including 10% Nearest 2%
Above 10% up to and including 50% Nearest 5%
Above 50% Nearest 10%

So the same "round it" step has four different step sizes depending on where the number lands.

What Math.round gets wrong

Using the current Daily Values (Iron 18 mg, Calcium 1,300 mg, Potassium 4,700 mg):

Nutrient Amount Raw %DV Math.round FDA
Iron 1.3 mg 7.2% 7% 8%
Iron 1.9 mg 10.6% 11% 10%
Calcium 310 mg 23.8% 24% 25%
Calcium 20 mg 1.5% 2% 0%
Potassium 55 mg 1.2% 1% 0%

None of these look dramatic on their own. Across a few thousand generated labels, they add up to a lot of non-compliant panels.

The implementation

// Vitamin/mineral %DV per 21 CFR 101.9(c)(8)(iii)
export function vitaminMineralPercent(percent) {
  if (!(percent > 0)) return 0;
  // Small epsilon so float noise like 4.999999 doesn't round the wrong way
  const near = (step) => Math.round(percent / step + 1e-9) * step;
  if (percent < 2) return 0;
  if (percent <= 10) return near(2);
  if (percent <= 50) return near(5);
  return near(10);
}

export function calculateDV(nutrient, value, dvTable = DAILY_VALUES, opts = {}) {
  const standard = dvTable[nutrient];
  if (!standard || standard.value === 0) return 0;
  const percent = (value / standard.value) * 100;
  if (opts.fda !== false && isVitaminOrMineral(nutrient)) {
    return vitaminMineralPercent(percent);
  }
  return Math.round(percent);
}
Enter fullscreen mode Exit fullscreen mode

Two details worth calling out:

  • The boundaries are inclusive. Exactly 10% stays in the 2% band and exactly 50% stays in the 5% band, so the checks are <=, not <.
  • The epsilon. percent / step can land on values like 2.4999999999 because of binary floating point. Adding 1e-9 keeps a true midpoint rounding up the way you'd expect without changing any real value.

Trap 2: a 0 g amount with a non-zero %DV

Amounts get rounded too (fat under 0.5 g is declared as 0 g, sodium under 5 mg as 0 mg, and so on). If you compute %DV from the raw amount, you can end up printing 0 g next to 1%, which reads as a mistake to anyone checking the panel. We force the %DV to 0 whenever the declared amount rounds to zero:

const rounded = roundNutrient(nutrient, raw);
const declaredZero = rounded.display === '0';
const dv = declaredZero ? 0 : calculateDV(nutrient, raw, dvTable);
Enter fullscreen mode Exit fullscreen mode

Trap 3: these rules are American

We generate labels for 152 formats in 114 countries, and other regulators have their own rounding. Canada, for example, has its own Daily Values and rules, so the US increments must not leak into the bilingual Canadian table. That's what the opts.fda === false escape hatch is for: the Canadian renderer opts out and keeps its existing output byte for byte.

How we made sure nothing else moved

Changing a shared formatter in a label generator is scary, because every format depends on it. Before shipping, we:

  1. Re-rendered every label format and diffed it against a stored snapshot, checking that only the vitamin and mineral %DV numbers changed.
  2. Ran our logic test suite and checked the boundary cases: 1.99% → 0%, 2% → 2%, 10% → 10%, 10.01% → 10%, 50% → 50%, 50.01% → 50%.
  3. Regenerated the static label images for our food pages and spot-checked live pages against hand calculations.

If you're building anything that outputs regulated numbers, the snapshot diff is the part I'd copy. It turns "I think this only changed one thing" into something you can prove.

Try it

You can check any value with our free % Daily Value calculator, and the full rule set is written up in our US FDA nutrition label guide.

This post is about implementation, not legal advice. Always confirm label requirements against the current regulation.

Top comments (0)