{"slug": "computing-who-growth-percentiles-on-device", "title": "Computing WHO growth percentiles on-device", "summary": "A developer released who-growth-standards, an MIT-licensed, zero-dependency library that computes WHO child growth percentiles on-device for a baby journal app. The project bundles all WHO LMS tables and handles edge cases such as L=0 and catastrophic cancellation in the Box-Cox transform, which naive implementations often miss, leading to NaN or inaccurate percentiles.", "body_md": "Every baby tracker shows growth percentiles. \"Your daughter is in the 72nd percentile for weight.\" It looks like a lookup — find the row for her age, compare, print a number.\n\nIt isn't. And the ways it goes wrong are interesting enough to be worth writing down.\n\nI ended up implementing this properly for a baby journal app, and published the result as [ who-growth-standards](https://github.com/SunnySeedApp/who-growth-standards) — MIT, zero dependencies, all the WHO tables bundled. This post is the reasoning behind it.\n\nThe WHO Child Growth Standards don't publish percentiles directly. They publish three numbers per age per sex: **L**, **M** and **S**.\n\nThat last one exists because growth data isn't normally distributed. A 3-month-old can weigh twice the median; none can weigh half of it and survive. The distribution has a long right tail, and L is what stretches the scale to make it symmetric.\n\nThe z-score comes out as:\n\n```\nz = ((X / M)^L − 1) / (L × S)\n```\n\nThen the percentile is just the normal CDF of that z-score. Two lines of code:\n\n```\nexport function lmsToZScore(x: number, l: number, m: number, s: number): number {\n  return (Math.pow(x / m, l) - 1) / (l * s);\n}\n```\n\nShip it, right?\n\nLook at the formula again. When `L`\n\nis zero, you divide by zero.\n\nThis isn't hypothetical. Across the WHO tables, L values drift with age and cross or approach zero in several indicators. Head-circumference-for-age publishes L = 1 exactly at birth; BMI-for-age starts negative and moves through zero territory as children grow.\n\nThe Box-Cox transform has a defined limit there, and it's the logarithm:\n\n```\nexport function lmsToZScore(x: number, l: number, m: number, s: number): number {\n  return l === 0\n    ? Math.log(x / m) / s\n    : (Math.pow(x / m, l) - 1) / (l * s);\n}\n```\n\nMiss this branch and you get `Infinity`\n\nor `NaN`\n\nfor real children at real ages — usually silently, because nobody validates a percentile that came back as `NaN`\n\nuntil a parent screenshots it.\n\nThere's a related trap I hit while writing tests. My first test asserted continuity: that as L approaches zero, the power form converges to the logarithmic one. It failed at `L = 1e-12`\n\n.\n\nThat's not a bug in the maths — it's catastrophic cancellation. `(X/M)^L`\n\nfor tiny L is `1 + L·ln(X/M) + …`\n\n, a number extremely close to 1. Subtracting 1 destroys most of the significant digits, and the smaller L gets, the worse the result. Agreement is best around `1e-6`\n\nand degrades below `1e-8`\n\n.\n\nThe test now says so explicitly, because the naive version of that assertion looks correct and fails for reasons that take an hour to understand:\n\n``` js\nit(\"approaches the logarithmic form as L → 0\", () => {\n  // Cannot be pushed arbitrarily close to zero: (X/M)^L − 1 loses significant\n  // digits catastrophically for tiny L, so agreement gets *worse* below ~1e-8.\n  const atZero = lmsToZScore(12, 0, 10, 0.12);\n  expect(lmsToZScore(12, 1e-6, 10, 0.12)).toBeCloseTo(atZero, 4);\n});\n```\n\nThe WHO publishes these as Excel files, one per indicator per sex, at `cdn.who.int`\n\n. Six indicators × two sexes = twelve files, roughly 17 000 rows of LMS triples in total.\n\nI've seen implementations that hardcode a dozen anchor points and interpolate between them. That's how you end up several percentiles off in the middle of the range — invisible in testing, wrong in production.\n\nThe right move is to bundle all of it, and to **generate** rather than hand-copy. My repo has a script that downloads the source files and emits typed TypeScript modules:\n\n```\nSOURCES = {\n    \"wfa\": (\"weight-for-age/expanded-tables/wfa-{sex}-zscore-expanded-tables.xlsx\",\n            \"day\", \"Weight-for-age\"),\n    \"lhfa\": (\"length-height-for-age/expandable-tables/lhfa-{sex}-zscore-expanded-tables.xlsx\",\n             \"day\", \"Length/height-for-age\"),\n    # …\n}\n```\n\nTwo practical notes. First, the URL patterns are inconsistent — some indicators live under `expanded-tables`\n\n, one under `expandable-tables`\n\n, and one file is `-table.xlsx`\n\nwhile its sibling is `-tables.xlsx`\n\n. Finding them took longer than parsing them.\n\nSecond: generated data files should be reproducible. Re-running my generator produces byte-identical output, which means the tables in the repo are verifiably the WHO's numbers and not something that drifted through a manual edit three commits ago.\n\nAge-based indicators come at **daily** resolution — 1857 rows covering 0 to 1856 days, the full 0–5 years. Weight-for-length and weight-for-height are indexed by centimetres in 0.1 cm steps.\n\nWith daily tables you might think interpolation is unnecessary. It isn't — real applications pass fractional values.\n\nA measurement taken at 100.5 days. A length of 74.35 cm. Whether you floor, round, or interpolate changes the answer, and the difference is largest exactly where the curve is steepest — the first weeks of life, which is when parents check most obsessively.\n\nLinear interpolation between adjacent grid points is enough here, because the grid is dense relative to how fast L, M and S change. But it should be a deliberate choice rather than an accident of `Math.floor`\n\n.\n\nHere's the case that convinced me this deserved to be a library rather than a file in one app.\n\nThe WHO standards describe children **born at term**. Apply them directly to a baby born at 32 weeks, and every comparison is against children who had eight extra weeks to grow.\n\n``` js\nconst chronological = 120;                                // days since birth\nconst corrected = correctedAgeInDays(chronological, 32);  // → 64\n\nweightForAge(5.2, { sex: \"male\", ageDays: chronological }).zScore;  // −2.53\nweightForAge(5.2, { sex: \"male\", ageDays: corrected }).zScore;      // −0.69\n```\n\nSame baby. Same weight. Same day.\n\nUncorrected, that's `−2.53`\n\n— below the WHO cut-off, the range where a clinician starts investigating. Corrected, it's `−0.69`\n\n— unremarkable, middle of the normal band.\n\nIf your app skips this, you are showing parents of premature babies a red flag that shouldn't be there. Given that these parents have usually just spent weeks in a NICU, that's not a rounding error, it's a cruelty.\n\nThe correction itself is trivial arithmetic:\n\n```\nexport function correctedAgeInDays(ageDays: number, gestationalAgeWeeks: number): number {\n  if (gestationalAgeWeeks >= 40) return ageDays;\n  return Math.max(0, ageDays - (40 - gestationalAgeWeeks) * 7);\n}\n```\n\nWhat isn't trivial is knowing it's needed, and knowing when to stop — correction is conventionally applied until 2 years, or 3 for extreme prematurity. That's a clinical judgement, so the library computes the corrected age and leaves the cut-off to the caller.\n\nOne more thing worth stating, because it's a common mix-up: correction applies to **growth and development**, never to vaccination schedules. Those follow chronological age.\n\nTwo reasons, one obvious and one less so.\n\nThe obvious one is privacy. The input is a child's weight, height and date of birth. Sending that to a server to divide two numbers is a strange trade. In the app this came from, the percentile calculation happens entirely on the device — the measurements never leave the phone for that purpose.\n\nThe less obvious one is that **the network is the least reliable part of the stack**, and parents log measurements in exactly the places where it fails: a paediatrician's basement office, a hospital corridor, home at 3am with the wifi router two floors down. A percentile that needs a round-trip is a percentile that sometimes isn't there.\n\nThe whole dataset is about 500 KB unminified — the size of a couple of photos. There's no technical reason to put it behind an API.\n\nIt computes numbers. It does not interpret them.\n\nThere's a `classify()`\n\nhelper that reports where a z-score falls against WHO cut-offs, and it's tempting to read that as a verdict. It isn't. Those cut-offs describe a reference population. A child at the 3rd percentile can be perfectly healthy and simply small; a child at the 50th can have something going on. That judgement belongs to someone who has met the child.\n\nOther limits worth knowing:\n\n```\nnpm install who-growth-standards\njs\nimport { weightForAge, ageInDays, classify } from \"who-growth-standards\";\n\nconst age = ageInDays(new Date(\"2025-11-14\"));\nconst result = weightForAge(8.9, { sex: \"female\", ageDays: age });\n\nresult.zScore;            // 0.5993\nresult.percentile;        // 72.55\nresult.median;            // 8.27 kg at this age\nclassify(result.zScore);  // \"normal\"\n```\n\nSix indicators, both sexes, out-of-range input throws by default with opt-in clamping, full TypeScript types, no runtime dependencies.\n\nIt came out of building [Sunny Seed](https://sunnyseed.app), a baby journal that does this calculation on the device. Extracting it seemed more useful than leaving it buried in an app — the maths is the same for everyone, and the failure modes above are worth not rediscovering one at a time.\n\nIf you spot something wrong in it, issues and PRs are welcome. Especially if you know the WHO 2007 reference well enough to add it.", "url": "https://wpnews.pro/news/computing-who-growth-percentiles-on-device", "canonical_source": "https://dev.to/romankoropets/computing-who-growth-percentiles-on-device-4b5n", "published_at": "2026-08-20 13:04:15+00:00", "updated_at": "2026-08-20 13:16:45.139328+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["WHO", "who-growth-standards", "SunnySeedApp"], "alternates": {"html": "https://wpnews.pro/news/computing-who-growth-percentiles-on-device", "markdown": "https://wpnews.pro/news/computing-who-growth-percentiles-on-device.md", "text": "https://wpnews.pro/news/computing-who-growth-percentiles-on-device.txt", "jsonld": "https://wpnews.pro/news/computing-who-growth-percentiles-on-device.jsonld"}}