DEV Community

Cover image for Building a Savings Calculator in JavaScript: The APY Mistake That Inflates Results
kamal singh
kamal singh

Posted on AI-assisted

Building a Savings Calculator in JavaScript: The APY Mistake That Inflates Results

A savings calculator can produce a convincing result while using the wrong formula.

One common mistake is taking an advertised APY, dividing it by 12, and compounding that monthly rate.

APY already includes the effect of compounding. Applying the wrong conversion can inflate the projection.

I run PennyRatio, a collection of personal finance calculators. Here is a standalone JavaScript example showing how to model savings growth, make the assumptions visible, and check the results.

APY is not a nominal annual interest rate

Suppose an account advertises 4% APY.

For a $1,000 balance held for a full year under the assumed conditions, that corresponds to $40 in interest.

But this calculation produces something different:

const monthlyRate = 0.04 / 12;
const balance = 1000 * (1 + monthlyRate) ** 12;

console.log(balance.toFixed(2));
// "1040.74"
Enter fullscreen mode Exit fullscreen mode

We have turned a 4% annual yield into approximately 4.074%.

Dividing by 12 is appropriate when converting a nominal annual rate into its monthly periodic rate for monthly compounding. It does not preserve an advertised APY.

For an equivalent monthly growth rate, use:

const apy = 0.04;
const monthlyRate = (1 + apy) ** (1 / 12) - 1;

const balance = 1000 * (1 + monthlyRate) ** 12;

console.log(balance.toFixed(2));
// "1040.00"
Enter fullscreen mode Exit fullscreen mode

This preserves the annual yield.

It is still a simplified model, not a reproduction of a bank’s daily interest calculations or crediting schedule.

Define the assumptions first

For this example:

  • APY remains constant.
  • Contributions happen at the end of each month.
  • Interest stays in the account.
  • There are no withdrawals, fees, or taxes.
  • Growth uses the equivalent monthly rate.
  • Rounding happens for display, not after every calculation.

These assumptions determine what the result means.

For example, a month-end contribution earns no interest during that month in this model.

Build a readable projection function

A month-by-month loop makes the calculation order easy to inspect:

function projectSavings({
  initialBalance,
  monthlyDeposit,
  apyPercent,
  months,
}) {
  const amounts = [
    initialBalance,
    monthlyDeposit,
    apyPercent,
  ];

  if (
    !amounts.every(Number.isFinite) ||
    amounts.some(value => value < 0)
  ) {
    throw new RangeError(
      "Balance, deposit, and APY must be finite, non-negative numbers."
    );
  }

  // A practical limit for this educational example: 100 years.
  if (
    !Number.isInteger(months) ||
    months < 0 ||
    months > 1200
  ) {
    throw new RangeError(
      "Months must be an integer between 0 and 1200."
    );
  }

  const monthlyRate =
    (1 + apyPercent / 100) ** (1 / 12) - 1;

  let balance = initialBalance;

  for (let month = 0; month < months; month++) {
    balance *= 1 + monthlyRate;
    balance += monthlyDeposit;
  }

  const totalContributions =
    initialBalance + monthlyDeposit * months;

  if (
    !Number.isFinite(balance) ||
    !Number.isFinite(totalContributions)
  ) {
    throw new RangeError(
      "The projection exceeds the supported numeric range."
    );
  }

  return {
    endingBalance: balance,
    totalContributions,
    estimatedInterest: balance - totalContributions,
  };
}
Enter fullscreen mode Exit fullscreen mode

The function accepts numeric inputs, not raw form strings.

That distinction matters because:

Number(""); // 0
Enter fullscreen mode Exit fullscreen mode

An empty field should not silently become a valid zero. Check for missing inputs before converting them, and display validation messages beside the relevant fields.

Run a practical example

Let’s model:

  • $5,000 starting balance
  • $200 deposited at each month-end
  • 4% APY
  • 12 months
const result = projectSavings({
  initialBalance: 5000,
  monthlyDeposit: 200,
  apyPercent: 4,
  months: 12,
});

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

console.log(usd.format(result.endingBalance));
// "$7,643.69"

console.log(usd.format(result.totalContributions));
// "$7,400.00"

console.log(usd.format(result.estimatedInterest));
// "$243.69"
Enter fullscreen mode Exit fullscreen mode

Compare three hypothetical rates using the same assumptions:

Hypothetical APY Total contributions Ending balance Estimated interest
3% $7,400.00 $7,582.82 $182.82
4% $7,400.00 $7,643.69 $243.69
5% $7,400.00 $7,704.52 $304.52

These rates are examples, not current bank offers.

In this scenario, moving from 4% to 5% APY adds approximately $60.83 over one year.

Expressing the difference in dollars helps users understand the practical effect of a rate change.

Test the meaning of the calculation

Checking that a function returns a number is not enough. Test cases should verify the financial assumptions.

function assertClose(actual, expected, tolerance = 1e-8) {
  if (
    !Number.isFinite(actual) ||
    Math.abs(actual - expected) > tolerance
  ) {
    throw new Error(
      `Expected ${expected}, received ${actual}`
    );
  }
}

// Zero APY: growth comes only from deposits.
assertClose(
  projectSavings({
    initialBalance: 1000,
    monthlyDeposit: 100,
    apyPercent: 0,
    months: 12,
  }).endingBalance,
  2200
);

// No deposits: 4% APY means 4% growth over one year.
assertClose(
  projectSavings({
    initialBalance: 1000,
    monthlyDeposit: 0,
    apyPercent: 4,
    months: 12,
  }).endingBalance,
  1040
);

// A month-end deposit earns no first-month interest.
assertClose(
  projectSavings({
    initialBalance: 0,
    monthlyDeposit: 200,
    apyPercent: 4,
    months: 1,
  }).endingBalance,
  200
);

// Zero months: no growth or monthly deposits.
assertClose(
  projectSavings({
    initialBalance: 5000,
    monthlyDeposit: 200,
    apyPercent: 4,
    months: 0,
  }).endingBalance,
  5000
);

// Check the worked example to within half a cent.
assertClose(
  projectSavings({
    initialBalance: 5000,
    monthlyDeposit: 200,
    apyPercent: 4,
    months: 12,
  }).endingBalance,
  7643.69,
  0.005
);
Enter fullscreen mode Exit fullscreen mode

These checks help catch incorrect rate conversion, contribution timing, and boundary handling.

JavaScript floating-point arithmetic also needs additional care in accounting and payment systems. This example is for educational projections, not ledger calculations.

Make the result understandable

A useful result panel should show three separate values:

  1. Total contributions: the user’s own money.
  2. Estimated interest: projected growth beyond those contributions.
  3. Ending balance: contributions plus estimated interest.

Place the key assumptions beside the result:

Assumes constant APY and month-end deposits. Excludes fees and taxes. Actual earnings may differ.

Real account rates can change. Deposit dates, balance requirements, fees, and bank calculation methods can also affect earnings.

A calculator should help someone explore a scenario without presenting its output as a guaranteed payout.

Explore the example further

You can explore savings scenarios with the PennyRatio HYSA Calculator.

Disclosure: PennyRatio is my project. The code in this article is a standalone teaching example; calculator features and assumptions should be checked separately.

For the official APY calculation framework, see the CFPB’s Appendix A to Regulation DD.

Make the assumptions as easy to inspect as the answer.

How do you test rate conversions and deposit timing in your own calculators?

Top comments (0)