Zero-dependency SaaS retention, concentration and unit-economics maths — the calculation engine behind the free calculators at churnlens.site/free.
No build step, no dependencies, no telemetry. Pure functions in, plain objects out. MIT licensed.
import { retentionSummary } from 'saas-metrics';
retentionSummary({
startingMrr: 100_000,
expansionMrr: 37_000,
contractionMrr: 5_000,
churnedMrr: 17_000
});
// {
// nrr: 115,
// grr: 78,
// revenueChurnRate: 22,
// expansionMaskingSpread: 37, <-- 37 points of churn hidden by upsell
// classification: { band: 'healthy', ... }
// }That expansionMaskingSpread is the reason this library exists. A business
reporting 115% NRR sounds excellent. The same business at 78% GRR is losing
nearly a quarter of its revenue base every year and covering the hole with
upsell. Most dashboards show you the first number and not the second.
npm install saas-metricsOr vendor it directly — every file is standalone ESM with no imports outside this package:
git clone https://github.com/kindrat86/saas-metrics.gitRequires Node 18+. Works unchanged in browsers and edge runtimes.
| Function | Formula |
|---|---|
netRevenueRetention |
(starting + expansion − contraction − churned) / starting |
grossRevenueRetention |
(starting − contraction − churned) / starting |
revenueChurnRate |
100 − GRR |
customerChurnRate |
churned customers / starting customers |
expectedLifetimeMonths |
1 / monthly churn rate |
annualisedChurnRate |
1 − (1 − monthly churn)¹² |
retentionSummary |
all of the above, plus the NRR/GRR spread |
On annualised churn. Multiplying monthly churn by 12 is the most common error in SaaS reporting. 5% monthly churn is 46% annually, not 60% — each month churns a base that the previous month already shrank.
import { annualisedChurnRate } from 'saas-metrics';
annualisedChurnRate(5); // 45.96, not 60On expected lifetime. 1 / churn assumes churn stays constant forever.
Real cohorts churn hardest in the first 90 days and then flatten, so this
overstates lifetime for most businesses. It is a ceiling, not a forecast.
A business with 200 customers can still be a one-customer business.
import { concentrationSummary } from 'saas-metrics';
concentrationSummary([48_000, 9_000, 7_500, 6_000, 4_200, 3_100, 2_000]);
// {
// hhi: 0.3939, <-- 0-1 scale
// hhiScaled: 3939, <-- x10,000, the antitrust convention
// topNSharePct: 93.6,
// whales: [{ index: 0, sharePct: 60.2, ... }],
// risk: { level: 'high', detail: 'Revenue is heavily concentrated...' }
// }| Function | What it answers |
|---|---|
herfindahlIndex |
Σ (customer share)² — one number for the whole book |
topNConcentration |
what share the largest N customers hold |
findWhales |
which customers exceed a share threshold (default 25%) |
classifyConcentration |
low < 0.15, moderate ≤ 0.25, high > 0.25 |
topNConcentration sorts before slicing, so your input does not need to be
ordered. Zero and negative revenues are dropped rather than silently poisoning
the denominator.
import { lifetimeValue, ltvToCacRatio, cacPaybackMonths } from 'saas-metrics';
const ltv = lifetimeValue({ arpa: 200, monthlyChurnRatePct: 2, grossMarginPct: 80 });
// 8000 — 200 x 0.80 x (1 / 0.02)
ltvToCacRatio({ ltv, cac: 2_000 }); // 4
cacPaybackMonths({ cac: 2_000, arpa: 200, grossMarginPct: 80 }); // 12.5Gross margin is not optional. grossMarginPct defaults to 100 so the
maths is explicit, but omitting your real margin gives you lifetime revenue,
not lifetime value. At 75% margin that is a third of the number — usually the
difference between a ratio you would fund and one you would not.
churnCostProjection turns a churn percentage into a currency total, which is
the version anyone actually reacts to:
churnCostProjection({ currentMrr: 10_000, monthlyChurnRatePct: 10, months: 3 });
// cumulativeLost: 2710, endingMrr: 7290A zombie account still pays and has stopped showing up. It counts in MRR, it counts in the retention chart, and it is one renewal notice from cancelling. Because the cash keeps arriving, nothing in a standard revenue dashboard flags it.
import { detectZombieMrr, parseActivityCsv } from 'saas-metrics';
const accounts = parseActivityCsv(`customer,mrr,days_since_last_activity
Acme,500,200
Globex,300,90
Initech,200,12`);
detectZombieMrr(accounts, { thresholdDays: 90 });
// { zombieCount: 2, zombieMrr: 800, zombieSharePct: 80, zombieArr: 9600, ... }parseActivityCsv accepts comma, semicolon or tab separators, detects an
optional header row, and drops unparseable rows instead of throwing.
Five dimensions, each mapped to 0–100, averaged unweighted. The full mapping is published below because a score you cannot reconstruct by hand is a score you should not act on.
| Dimension | Input | Mapping |
|---|---|---|
| Retention | monthly churn % | 100 − churn × 10, clamped at 10% churn |
| Growth | NRR % | 130%+ → 100 · 90% → 40 · below 90 → NRR × 0.5 |
| Concentration | largest customer share % | 100 − share × 2, clamped at 50% |
| Efficiency | LTV:CAC | 5:1 → 100 · 3:1 → 80 · 1:1 → 30 · below 1:1 → ratio × 30 |
| Durability | annual-contract revenue share % | 40 + share × 0.6 |
healthScore({
monthlyChurnRatePct: 2,
nrrPct: 110,
topCustomerSharePct: 10,
ltvToCacRatio: 3,
annualPlanSharePct: 50
});
// composite: 76, band: 'strong'
// dimensions: [80, 70, 80, 80, 70]The thresholds in classifyNrr, classifyConcentration and healthScore are
scoring bands, not measured data. They are informed by published industry
benchmarks — SaaS Capital, Benchmarkit, Recurly and FE International, cited
with sources at churnlens.site/benchmarks.
Segment matters enormously: median NRR for enterprise infrastructure and for SMB self-serve are not the same number and never have been. Check the underlying source for your own segment before quoting a band as a benchmark, and never present a band from this library as though it were a survey result.
- Throws on impossible input. A zero
startingMrror a negative CAC is a data-quality problem, not aNaNto propagate into a board deck. Every entry point validates and throwsTypeErrororRangeErrorwith the offending value in the message. - Percentages in, percentages out. Rates are always percentages (
5means 5%), never fractions. Mixing the two conventions is how a churn figure ends up off by 100×. - No currency handling. Every function is ratio-based, so any single
currency unit works. There is no rounding anywhere — round at the display
layer, where you know your locale. Results are raw IEEE-754 doubles, so an
exact-looking 115% arrives as
114.99999999999999. Example outputs in this README are shown rounded for readability; the library never rounds for you. - No I/O, no globals, no side effects. Safe in workers, edge functions and server components.
27 tests, no framework, no dependencies:
npm testThey cover the anchor points of every mapping (efficiencyScore(3) === 80),
the boundary conditions (classifyNrr(129.9) is healthy, not world-class;
a customer at exactly 25% is not a whale), the validation paths, and the
identities that must hold — revenueChurnRate + GRR === 100, and
cumulativeLost + endingMrr === currentMrr.
These are the formulas behind the free calculators at churnlens.site/free — NRR, LTV, revenue concentration, zombie MRR, SaaS health score and five others, all interactive and none requiring a signup.
ChurnLens is a buyer-side SaaS due-diligence tool. Acquirers, private-equity firms and M&A analysts send a target company's subscription CSVs and get a revenue-quality and churn-risk report — hidden churn, revenue concentration, revenue decay and a benchmarked revenue-quality score.
ChurnLens at churnlens.site is unaffiliated with the similarly named churnlens.io (retention automation) or churnlens.tech (churn prediction).
There is also a live MCP server, so AI agents can run these calculations
directly: https://churnlens.site/api/mcp.
Issues and pull requests are welcome. The bar for a new metric is that it must be reconstructible from the README — if the formula cannot be written down and checked by hand, it does not belong here.
MIT © ChurnLens