Most debt payoff calculators make you choose a camp: snowball if you want motivation, avalanche if you want the mathematically cheapest path. That part is familiar. What I found more interesting here was the in-between setting.
If you give people a slider instead of a binary choice, you suddenly have to answer a much messier question: what does "60% avalanche, 40% snowball" actually mean in code? Not in personal-finance-blog language, but in the part that has to rank real debts with different balances and APRs every month.
The Vue component for this simulator is pretty honest about its answer. It doesn't precompute two plans and interpolate between the totals. It builds a blended priority score per debt, reruns the payoff order month by month, and then stores just enough state in localStorage to let the calculator behave more like an ongoing tracker.
The slider is really a weighted ranking system, not a blend of two finished plans
The key implementation detail lives in rankDebts():
const rankDebts = (debts, weight) => {
if (!debts.length) return [];
const rates = debts.map((d) => Number(d.rate) || 0);
const balances = debts.map((d) => Number(d.balance) || 0);
const minRate = Math.min(...rates);
const maxRate = Math.max(...rates);
const minBal = Math.min(...balances);
const maxBal = Math.max(...balances);
const rateRange = maxRate - minRate || 1;
const balRange = maxBal - minBal || 1;
return debts
.map((d) => {
const interestRank = ((Number(d.rate) || 0) - minRate) / rateRange;
const balanceRank = 1 - ((Number(d.balance) || 0) - minBal) / balRange;
const score = weight * interestRank + (1 - weight) * balanceRank;
return { ...d, score };
})
.sort((a, b) => b.score - a.score || a.balance - b.balance);
};
I like this because it's specific. "Avalanche" here means "higher normalized APR gets a higher score." "Snowball" means "smaller normalized balance gets a higher score." The slider is just the weight between those two.
That has a few consequences that aren't obvious from the UI:
- The ranking is relative to the current debt list, not absolute. Add a new high-interest debt and everybody else's interest score gets rescaled.
- A 50/50 slider position is not "half of two strategies." It's one blended score computed fresh from the current balances and rates.
- Ties fall back to
a.balance - b.balance, so if two debts land on the same weighted score, the smaller balance wins.
That's a neat design choice because it keeps the behavior continuous. A small slider move changes the order gradually instead of snapping between two separate modes. It also explains why the priority list in the UI can change as balances shrink, even when the APRs stay fixed.
The monthly simulation is deliberately simple: interest, minimums, then one focused overpayment
The payoff loop itself is also straightforward in a good way:
while (debts.some((d) => d.remaining > 0.5) && month < maxMonths) {
month++;
debts.forEach((d) => {
if (d.remaining > 0.5) {
const interest = d.remaining * (d.rate / 100 / 12);
d.remaining += interest;
d.totalInterestPaid += interest;
totalInterest += interest;
}
});
debts.forEach((d) => {
if (d.remaining > 0.5) {
const pay = Math.min(d.minPayment, d.remaining);
d.remaining -= pay;
}
});
let pool = Number(extraPayment) || 0;
if (pool > 0) {
const active = debts.filter((d) => d.remaining > 0.5);
const ranked = rankDebts(
active.map((d) => ({ id: d.id, rate: d.rate, balance: d.remaining })),
weight,
);
for (const r of ranked) {
if (pool <= 0) break;
const target = debts.find((d) => d.id === r.id);
const pay = Math.min(pool, target.remaining);
target.remaining -= pay;
pool -= pay;
}
}
}
The order matters. Every month the component:
- accrues interest using
annual rate / 12 - subtracts each debt's minimum payment
- throws the extra-payment pool at the highest-ranked remaining debt
That third step is more aggressive than some spreadsheet-style payoff models because the extra pool is intentionally concentrated. It doesn't spread the extra money across all debts proportionally; it keeps paying down the top-ranked debt until either that debt hits zero or the pool runs out, then spills into the next one if there's anything left.
Two more details jumped out at me while reading it:
First, the ranking is recalculated from current remaining balances, not original balances. So a mixed strategy can genuinely evolve over time instead of being locked to month-one ordering.
Second, the simulator stops at maxMonths = 600, which is where the 50-year warning comes from. That's not just UI copy; it's a hard cap in the loop. If the balances are still above the 0.5 threshold at that point, the result is flagged as effectively unpayable under the current inputs.
"Save progress" works because the simulator already computes the next month for free
The nicest part of the component, honestly, is that it doesn't build a second bookkeeping system for progress tracking. It reuses the same simulation output.
Inside simulatePayoff(), the component captures the balances after the first simulated month:
if (month === 1) {
firstMonthAllocation = debts.map((d) => ({
id: d.id,
newBalance: Math.max(0, d.remaining),
}));
}
Then the "log this month's payment" action just commits that projected first month back into reactive state:
const applyThisMonthPayment = () => {
const sim = simulation.value;
sim.firstMonthAllocation.forEach((alloc) => {
const debt = state.debts.find((d) => d.id === alloc.id);
if (debt) {
debt.balance = Math.round(alloc.newBalance);
}
});
state.debts = state.debts.filter((d) => d.balance > 0.5);
state.monthsTracked = (state.monthsTracked || 0) + 1;
saveProgress();
};
That means the page isn't just an estimator. It's using the estimator as a one-step state transition: "given my current inputs, what would one month of payments do?" and then "okay, now make that the new current state."
The persistence layer is just browser storage:
const payload = {
debts: state.debts.map((d) => ({ ...d })),
extraPayment: state.extraPayment,
strategyWeight: state.strategyWeight,
startingTotalDebt: state.startingTotalDebt,
monthsTracked: state.monthsTracked,
savedAt: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
There are no accounts, no backend sync, and no amortization history table hiding somewhere else. The progress bar is based on a saved baseline total:
const pct =
(1 - totalCurrentBalance.value / state.startingTotalDebt) * 100;
So what gets preserved is "where I started" plus "what the balances are now," which is a very pragmatic amount of state for a tool like this. It's also why the whole thing can stay a static front-end page and still feel like it remembers you.
The honest gotchas: it's useful, but it's not a perfect snowball simulator or a bank-grade ledger
The biggest non-obvious limitation is that the code does not fully implement the classic snowball roll-up behavior it describes in the copy.
In a textbook snowball, once one debt is cleared, that debt's minimum payment gets added to the money attacking the next debt. In this component, the extra pool is recreated each month from a fixed user input:
let pool = Number(extraPayment) || 0;
When a debt disappears, the simulator stops subtracting that debt's minimum payment in future months, which reduces total cash outflow. But it does not automatically add the freed minimum into pool. So unless the user manually increases the extra-payment field, the model becomes less aggressive than a true rolling snowball after each payoff.
There are a couple of other practical simplifications too:
- Balances are treated as paid off once
remaining <= 0.5, so sub-dollar leftovers are intentionally ignored. - When you log a month, the component writes
Math.round(alloc.newBalance)back into state, which drops cents entirely. - For
tw,cn,jp, andkr, the UI switches to ten-thousand-based amount entry (萬) instead of plain thousand-grouped numbers. That's thoughtful, but it also means screenshots across locales are not directly comparable unless you notice the unit system.
None of those choices are wrong for a planning tool. They just tell you what kind of tool this is: a fast front-end simulator built to compare strategies and track rough real-world progress, not something trying to exactly mirror every issuer's statement math.
When I want to sanity-check whether changing the blend actually moves the payoff date enough to matter, I don't really want a spreadsheet either, so I turned this into a small free tool: Debt Payoff Strategy Simulator.
Available in other languages
- 還債策略模擬器 — 繁體中文
- 还债策略模拟器 — 简体中文
- Debt Payoff Strategy Simulator — English
- 借金返済シミュレーター — 日本語
- 빚 상환 전략 시뮬레이터 — 한국어
- Simulateur de stratégie de remboursement de dettes — Français
- Симулятор стратегии выплаты долгов — Русский
- Schuldentilgungs-Strategie-Simulator — Deutsch
- Simulator Strategi Melunasi Utang — Bahasa Indonesia
- Simulador de Estrategia de Pago de Deudas — Español
- Trình Mô Phỏng Chiến Lược Trả Nợ — Tiếng Việt
- เครื่องมือจำลองกลยุทธ์ปลดหนี้ — ไทย
- Symulator Strategii Spłaty Długów — Polski
- Borç Ödeme Stratejisi Simülatörü — Türkçe
- Simulatore di Strategia di Estinzione Debiti — Italiano
- Simulador de Estratégia de Pagamento de Dívidas — Português
- Schuldaflossing Strategie Simulator — Nederlands
- Симулятор стратегії виплати боргів — Українська
Top comments (0)