<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 牛云鹏</title>
    <description>The latest articles on DEV Community by 牛云鹏 (@optimetricshub).</description>
    <link>https://dev.to/optimetricshub</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4195273%2F7ae5801e-1800-4bf1-97cd-a741e61dc6a0.png</url>
      <title>DEV Community: 牛云鹏</title>
      <link>https://dev.to/optimetricshub</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/optimetricshub"/>
    <language>en</language>
    <item>
      <title>Three SaaS metrics that never make the dashboard</title>
      <dc:creator>牛云鹏</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:34:01 +0000</pubDate>
      <link>https://dev.to/optimetricshub/three-saas-metrics-that-never-make-the-dashboard-1jki</link>
      <guid>https://dev.to/optimetricshub/three-saas-metrics-that-never-make-the-dashboard-1jki</guid>
      <description>&lt;p&gt;Every SaaS dashboard shows MRR, churn and CAC payback. Three metrics that explain far more about where a business is actually going almost never appear, because each one needs a definition agreed before it means anything: net revenue retention, the Rule of 40, and the magic number.&lt;/p&gt;

&lt;p&gt;Here is each one, and the specific way it gets quietly corrupted.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Net revenue retention — the denominator is the whole metric
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NRR = revenue from the starting cohort today ÷ revenue from the same cohort at period start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Above 100% means the existing base grows without a single new customer. Below 100% means you must win new business just to stand still.&lt;/p&gt;

&lt;p&gt;Two variants, and you should report both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;NRR&lt;/th&gt;
&lt;th&gt;GRR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Expansion included&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can exceed 100%&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answers&lt;/td&gt;
&lt;td&gt;is the base compounding?&lt;/td&gt;
&lt;td&gt;how much leaks out?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A company at 115% NRR and 82% GRR is growing its base while losing nearly a fifth of it every year. That dependency on continued upsell is invisible if you only quote NRR.&lt;/p&gt;

&lt;p&gt;The corruption is almost always the cohort. New customers signed during the period must be excluded from &lt;strong&gt;both&lt;/strong&gt; numerator and denominator. A customer who signed in March and bought more in June is a new logo expanding, not retention.&lt;/p&gt;

&lt;p&gt;The other two inflators: a list price rise applied across the base (real revenue, but it does not recur next year), and non-recurring revenue sitting in the starting base — professional services, onboarding fees, usage overage. Compute on recurring revenue only.&lt;/p&gt;

&lt;p&gt;Implementation, if you have customer id / plan / MRR per month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cohort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mrr &amp;gt; 0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;denom&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cohort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;num&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cohort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;nrr&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;denom&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this for twelve monthly cohorts and average. A single window picks up whatever renewal cycle happened to land inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Rule of 40 — the margin definition is the argument
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score = revenue growth rate (%) + profit margin (%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;30% growth with a 10% margin passes. So does 10% growth with a 30% margin. That is the point: it encodes a trade-off rather than demanding both.&lt;/p&gt;

&lt;p&gt;The ambiguity is "margin". Free cash flow margin is the original public-company definition and the hardest to flatter. EBITDA margin is the most common. Operating margin is stricter. Whichever you pick, keep it — switching definitions between periods moves the score by more than most operational improvements do.&lt;/p&gt;

&lt;p&gt;What the score does not tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It punishes deliberate investment.&lt;/strong&gt; Hiring a sales team ahead of demand scores badly this year and well next year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It treats all growth as equal.&lt;/strong&gt; 25% growth at 110% NRR is a compounding base; 25% at 75% NRR is a treadmill. Same score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small numbers distort it.&lt;/strong&gt; Below roughly $1m ARR the percentages are noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It ignores capital efficiency.&lt;/strong&gt; Growth bought with $3 of equity per $1 of ARR passes the rule and destroys returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use it as one of four signals, alongside NRR/GRR, CAC payback and runway. A company can pass the Rule of 40 and be nine months from insolvency if its cash conversion is poor.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Magic number — the lag is doing all the work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;magic = (ARR this quarter − ARR last quarter) ÷ S&amp;amp;M spend last quarter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Above 0.75 is considered efficient enough to increase spend. Below 0.5 means acquisition is not paying back fast enough to justify more.&lt;/p&gt;

&lt;p&gt;Three details decide whether the number means anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Net new ARR includes churn.&lt;/strong&gt; Using gross new business makes any retention problem disappear — exactly the failure the metric exists to catch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full S&amp;amp;M cost.&lt;/strong&gt; Salaries, commissions, ads, tools, agencies. Excluding salaries inflates it, often by 2x.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lag.&lt;/strong&gt; One quarter is convention. If your sales cycle is nine months, one quarter credits revenue to the wrong spend and understates efficiency badly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it hides: contract length (a 0.8 looks identical whether you bill monthly or prepay three years), gross margin (it compares ARR to spend, not gross profit to spend), and lifetime entirely. Enterprise at 0.6 with an 8-month payback can be better business than SMB at 0.9 with a 22-month payback.&lt;/p&gt;

&lt;p&gt;Use the magic number as the budget dial and CAC payback as the viability floor. When they disagree, the payback is usually right and the magic number is measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common thread
&lt;/h2&gt;

&lt;p&gt;All three fail the same way: the arithmetic is trivial and the definition is not. NRR needs a cohort rule, the Rule of 40 needs a margin rule, the magic number needs a lag rule. Decide those three once, write them down, and the numbers stop moving between meetings.&lt;/p&gt;




&lt;p&gt;Longer write-ups with worked examples and segment benchmarks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://optimetricshub.com/guides/net-revenue-retention" rel="noopener noreferrer"&gt;Net revenue retention&lt;/a&gt; — NRR vs GRR, cohort rules, the three inflators&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://optimetricshub.com/guides/rule-of-40" rel="noopener noreferrer"&gt;The Rule of 40&lt;/a&gt; — which margin, score bands, where it misleads&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://optimetricshub.com/guides/saas-magic-number" rel="noopener noreferrer"&gt;SaaS magic number&lt;/a&gt; — the lag problem, what it hides&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also built free no-signup calculators for &lt;a href="https://optimetricshub.com/tools/churn-calculator" rel="noopener noreferrer"&gt;churn&lt;/a&gt;, &lt;a href="https://optimetricshub.com/tools/runway-calculator" rel="noopener noreferrer"&gt;runway&lt;/a&gt; and &lt;a href="https://optimetricshub.com/tools/cac-payback-calculator" rel="noopener noreferrer"&gt;CAC payback&lt;/a&gt;, each showing its formula and derivation.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>Shipping an embeddable calculator: six things I got wrong first</title>
      <dc:creator>牛云鹏</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:18:23 +0000</pubDate>
      <link>https://dev.to/optimetricshub/shipping-an-embeddable-calculator-six-things-i-got-wrong-first-475h</link>
      <guid>https://dev.to/optimetricshub/shipping-an-embeddable-calculator-six-things-i-got-wrong-first-475h</guid>
      <description>&lt;p&gt;I wanted a calculator that a stranger could drop into their blog with one paste. What I ended up learning was mostly about what &lt;em&gt;not&lt;/em&gt; to put inside an iframe.&lt;/p&gt;

&lt;p&gt;The constraints, in the order they bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The widget cannot carry your site chrome
&lt;/h2&gt;

&lt;p&gt;The first version embedded the whole page: header nav, footer, cookie banner, ad slots. On my site that reads as a finished product. Inside someone else's article it reads as a website crammed into a letterbox, and the first thing the host does is delete it.&lt;/p&gt;

&lt;p&gt;So the embed route has to render &lt;strong&gt;only the tool&lt;/strong&gt;. In Vue that meant one flag on the route and one &lt;code&gt;v-if&lt;/code&gt; in the app shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- App.vue --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;header&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"!bare"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;PrivacyBanner&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"!bare"&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"bar"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;main&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;:class=&lt;/span&gt;&lt;span class="s"&gt;"bare ? 'px-3 py-3' : 'mx-auto max-w-5xl px-4 py-8'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;RouterView&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;footer&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"!bare"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/footer&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bare&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bare&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines of state, a handful of &lt;code&gt;v-if&lt;/code&gt;s. The alternative — a second app entry point — is the kind of duplication that quietly drifts until the embedded version shows last quarter's formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. No ads, and say so
&lt;/h2&gt;

&lt;p&gt;An ad inside your iframe renders on someone else's page. That is their inventory, their layout and their audience. It is also the fastest way to get the embed pulled and the domain remembered badly.&lt;/p&gt;

&lt;p&gt;The same applies to analytics in any form that a host would notice. Mine loads a site-wide tag in &lt;code&gt;index.html&lt;/code&gt;, which the embed inherits — that one I accepted because it is aggregate pageview counting and nothing about the widget's inputs leaves the browser. Anything more invasive would have to be stripped per-route.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;noindex&lt;/code&gt; the embed, canonical it to the real page
&lt;/h2&gt;

&lt;p&gt;Here is the trap: the embed page is a near-duplicate of the tool page. Left indexable, it competes with the page you actually want to rank.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"robots"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"noindex, follow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/tools/ltv-cac-calculator"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;follow&lt;/code&gt; rather than &lt;code&gt;noindex, nofollow&lt;/code&gt;: crawlers should still walk the attribution link back to the real page. And the canonical points at the tool page, not at itself — the embed is a rendering of that page, not a page of its own.&lt;/p&gt;

&lt;p&gt;Also: leave the embeds &lt;strong&gt;out of the sitemap&lt;/strong&gt;. If your sitemap is generated from a route table, exclude them explicitly, otherwise you are telling Google "please index these noindex pages" — which it will report as an error for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Size is a suggestion the host will ignore
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;iframe width="100%" height="640"&amp;gt;&lt;/code&gt; is what I ship, and roughly half of hosts will paste it into a column that is 500px wide. That is fine — the widget is responsive — but it means &lt;strong&gt;height cannot be computed from width&lt;/strong&gt;. A two-column form-plus-result layout that fits at 900px stacks at 500px and gets taller.&lt;/p&gt;

&lt;p&gt;Fix: measure it, do not guess. A 20-line script in the embed page posts its height to the parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ResizeObserver&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ltv-cac&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the host's one-liner listens. Ship the fixed-height iframe as the default so a host who pastes and walks away still gets something usable, and treat auto-resize as the upgrade.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`iframe[data-embed="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"]`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. &lt;code&gt;loading="lazy"&lt;/code&gt; is not optional
&lt;/h2&gt;

&lt;p&gt;An embed at the top of a long article otherwise blocks that article's first paint for a host who never asked for it. It is one attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"border:1px solid #e2e8f0;border-radius:12px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. The attribution link is the entire point
&lt;/h2&gt;

&lt;p&gt;Be honest about the incentive: the reason to build an embed is the link. So put it somewhere a human actually reads, make it small and legible, and do not hide it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/tools/ltv-cac-calculator"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  LTV:CAC Ratio Calculator
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; by Example — free, no signup, nothing stored.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I would not do: &lt;code&gt;target="_top"&lt;/code&gt; on that link (it hijacks the host's navigation) and a "powered by" that only appears after 30 days (nobody trusts it, and it gets patched out).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the whole thing cost
&lt;/h2&gt;

&lt;p&gt;One route per tool, one bare-rendering page, one component that prints the snippet with a copy button. No build step, no script tag, no third-party runtime — which also means nothing to sandbox, no CSP negotiation with the host, and no way for the embed to break their page.&lt;/p&gt;

&lt;p&gt;An iframe is the boring answer. For a widget that must never interfere with someone else's site, boring is the feature.&lt;/p&gt;




&lt;p&gt;The live version, if you want to see the bare rendering: &lt;a href="https://optimetricshub.com/embed/ltv-cac" rel="noopener noreferrer"&gt;embed page&lt;/a&gt;, and the &lt;a href="https://optimetricshub.com/tools/ltv-cac-calculator" rel="noopener noreferrer"&gt;copy-paste block&lt;/a&gt; it belongs to.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>vue</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Six SaaS metric formulas and the edge cases that break them</title>
      <dc:creator>牛云鹏</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:12:40 +0000</pubDate>
      <link>https://dev.to/optimetricshub/six-saas-metric-formulas-and-the-edge-cases-that-break-them-377l</link>
      <guid>https://dev.to/optimetricshub/six-saas-metric-formulas-and-the-edge-cases-that-break-them-377l</guid>
      <description>&lt;p&gt;Every SaaS metric calculator is five lines of arithmetic. The bugs are all in the edges.&lt;/p&gt;

&lt;p&gt;I wrote six of them — LTV:CAC, CAC payback, quick ratio, MRR/ARR, churn rate and runway — and wrote the tests before the UI. Below are the cases that return an answer which looks completely reasonable and is completely wrong. They are worth knowing even if you never build a calculator, because the same arithmetic sits inside every spreadsheet that reports these numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Zero churn makes LTV infinite
&lt;/h2&gt;

&lt;p&gt;Average customer lifetime is &lt;code&gt;100 / monthlyChurnPct&lt;/code&gt;. At zero churn that is &lt;code&gt;Infinity&lt;/code&gt;, so LTV is &lt;code&gt;Infinity&lt;/code&gt;, so the LTV:CAC ratio is &lt;code&gt;Infinity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing throws. The UI renders a number. The number is unbeatable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lifetimeMonths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;churnPct&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;churnPct&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ltv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lifetimeMonths&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;arpu&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;grossMargin&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lifetimeMonths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Return &lt;code&gt;null&lt;/code&gt; and render "unbounded" with a note that real cohorts always churn eventually. An explicit &lt;code&gt;null&lt;/code&gt; beats a helpful default, because a default can be silently summed into a total while &lt;code&gt;null&lt;/code&gt; has to be handled.&lt;/p&gt;

&lt;p&gt;Same trap on the other side: &lt;code&gt;CAC = 0&lt;/code&gt; makes the ratio infinite. Zero CAC is never true, it means the denominator was not filled in.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Annual contracts booked as one month
&lt;/h2&gt;

&lt;p&gt;A $1,200 annual plan is $100 of MRR. Booking the cash when it lands gives you this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;March MRR jumps by $1,200&lt;/li&gt;
&lt;li&gt;April MRR falls by $1,100 relative to March&lt;/li&gt;
&lt;li&gt;Every chart downstream — net new MRR, growth rate, quick ratio — inherits the spike and the crash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The twelve-fold error is not a rounding problem, it is a definition problem: &lt;strong&gt;MRR is a rate, not a receipt.&lt;/strong&gt; Amortise the contract across its term before it reaches any metric.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mrr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;billingPeriod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;annual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;planPrice&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;planPrice&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. LTV on revenue instead of gross profit
&lt;/h2&gt;

&lt;p&gt;The 3x rule of thumb assumes gross profit. Using revenue inflates LTV by &lt;code&gt;1 / grossMargin&lt;/code&gt; — at an 80% margin that is 25% too high, at 50% it is double.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: compares revenue to a cost&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ltv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arpu&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lifetimeMonths&lt;/span&gt;

&lt;span class="c1"&gt;// right: compares profit to a cost&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ltv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arpu&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;grossMargin&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lifetimeMonths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is nasty because the result still looks like a number. At a 70% margin a business at 2.1x looks like 3.0x, and the conclusion flips from "underinvesting" to "healthy".&lt;/p&gt;

&lt;h2&gt;
  
  
  4. CAC payback without the margin
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paybackMonths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cac&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arpu&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;grossMargin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop &lt;code&gt;grossMargin&lt;/code&gt; and payback shrinks by the margin: at 80% a real 6.25-month payback reports as 5.0. The rule of thumb is "under 12 months", so a 15-month payback reported as 12 sits exactly on the boundary and passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Quick ratio when nothing churned
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quickRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mrrLost&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mrrGained&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;mrrLost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A month with no churn is either a tiny base or a data problem. Rendering &lt;code&gt;Infinity&lt;/code&gt; makes it indistinguishable from a genuinely excellent month, and it will be copied into a board deck.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Money is not a float
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;0.1 + 0.2&lt;/code&gt; is &lt;code&gt;0.30000000000000004&lt;/code&gt;. It does not matter at these magnitudes until you start summing and comparing — and then &lt;code&gt;paybackMonths &amp;lt;= 12&lt;/code&gt; is false for what a human computes as 12.0.&lt;/p&gt;

&lt;p&gt;For a single calculation, round only at the point of display. If you are summing rows, store integer cents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;dp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;Every one of these is the same bug wearing a different hat: &lt;strong&gt;a missing value is not zero, and an unbounded value is not a large one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A calculator whose inputs are optional and whose outputs are numbers needs a third state. &lt;code&gt;null&lt;/code&gt; in the model, an explicit "not enough information" in the UI. The alternative is a dashboard that is confidently wrong, which is worse than one that is visibly empty.&lt;/p&gt;




&lt;p&gt;I put these six into free, no-signup calculators — &lt;a href="https://optimetricshub.com/tools/ltv-cac-calculator" rel="noopener noreferrer"&gt;LTV:CAC&lt;/a&gt;, &lt;a href="https://optimetricshub.com/tools/cac-payback-calculator" rel="noopener noreferrer"&gt;CAC payback&lt;/a&gt;, &lt;a href="https://optimetricshub.com/tools/quick-ratio-calculator" rel="noopener noreferrer"&gt;quick ratio&lt;/a&gt;, &lt;a href="https://optimetricshub.com/tools/mrr-calculator" rel="noopener noreferrer"&gt;MRR &amp;amp; ARR&lt;/a&gt;, &lt;a href="https://optimetricshub.com/tools/churn-calculator" rel="noopener noreferrer"&gt;churn&lt;/a&gt; and &lt;a href="https://optimetricshub.com/tools/runway-calculator" rel="noopener noreferrer"&gt;runway&lt;/a&gt;. Every one shows its formula and its derivation, and the edge cases above are covered by tests rather than by hope.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>saas</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
