<?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: Hao</title>
    <description>The latest articles on DEV Community by Hao (@edifierxuhao).</description>
    <link>https://dev.to/edifierxuhao</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%2F3885456%2Fee54478b-0559-41ce-83a3-27894ec744f0.png</url>
      <title>DEV Community: Hao</title>
      <link>https://dev.to/edifierxuhao</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edifierxuhao"/>
    <language>en</language>
    <item>
      <title>A run history cannot show you a run it never created</title>
      <dc:creator>Hao</dc:creator>
      <pubDate>Wed, 16 Sep 2026 11:35:14 +0000</pubDate>
      <link>https://dev.to/edifierxuhao/a-run-history-cannot-show-you-a-run-it-never-created-1fm3</link>
      <guid>https://dev.to/edifierxuhao/a-run-history-cannot-show-you-a-run-it-never-created-1fm3</guid>
      <description>&lt;p&gt;There is a failure mode in every event-driven system that its own logs are structurally incapable of reporting. Not "hard to find". Incapable.&lt;/p&gt;

&lt;p&gt;The system accepts a unit of work, answers success, and then the work does not happen. Nothing errors. No run is marked failed. No alert fires. The run history is not lying to you, it is telling the truth about a different layer: the message arrived and the server answered. Whether the operation actually occurred is a separate question that nothing in the transport path is in a position to answer.&lt;/p&gt;

&lt;p&gt;I spent the last few months measuring how often this happens on no-code automation platforms, and the thing I got asked repeatedly was not about the results. It was "how are you counting that". So I wrote the method down as a specification: what the metric is, what has to be excluded from it, the data contract that makes it computable, and the conditions a published number has to meet before it means anything. It is free, CC BY 4.0, and there is no code to install.&lt;/p&gt;

&lt;p&gt;This post is the engineering half of it. The parts worth reading even if you never touch the spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the run history cannot help
&lt;/h2&gt;

&lt;p&gt;Two structural reasons, and neither is a defect anyone can patch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A log cannot record a run that was never created.&lt;/strong&gt; If a trigger silently stops firing, if an event is deduplicated away, if a quota edge swallows it before a run exists, then there is no row. You cannot find the absence of a row by reading rows. Every "check your execution history" answer to this problem is answering a different question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step success is evaluated on transport status, by design.&lt;/strong&gt; A destination that returns &lt;code&gt;200 OK&lt;/code&gt; with &lt;code&gt;{"ok": false, "error": "..."}&lt;/code&gt; in the body has answered successfully at the only layer the platform can evaluate. The platform cannot know what failure looks like inside an arbitrary API's response schema without being told. So it records a green checkmark, and it is correct to.&lt;/p&gt;

&lt;p&gt;Those two together mean the measurement has to happen outside the system being measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;Three records and a reconciliation. This is the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The event&lt;/strong&gt;, written by a controller you operate, before it sends anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"unique per event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="s2"&gt;"which workload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"which system this copy went to"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"seq"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fired_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-16T04:40:15.062Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;run_id&lt;/code&gt; must be unique per event and preserved unchanged to the destination. That is what makes per-event reconciliation possible, and per-event reconciliation is what separates this from counting totals. &lt;strong&gt;Totals can match while the wrong records are missing.&lt;/strong&gt; If you take one thing from this post, take that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The receipt&lt;/strong&gt;, posted by every action in the workflow to a destination you control, echoing the event and adding which step produced it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"wf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"step"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"action1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fired_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The destination stamps &lt;code&gt;received_at&lt;/code&gt; on arrival. Latency is &lt;code&gt;received_at - fired_at&lt;/code&gt;, which includes platform processing and both network legs. Say so when you report it; it is not the platform's internal execution time and comparing the two is meaningless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The send outcome&lt;/strong&gt;, recorded by the controller for every event: did the platform accept it, and if not, why not.&lt;/p&gt;

&lt;p&gt;This field looks like bookkeeping and is actually load-bearing. Without it, an event with no receipt is ambiguous between &lt;em&gt;"the platform refused it, loudly, and we could have retried"&lt;/em&gt; and &lt;em&gt;"the platform accepted it and lost it"&lt;/em&gt;. Those are different findings. A measurement that cannot separate them is not a measurement.&lt;/p&gt;

&lt;p&gt;Record the reason, not just the fact. I learned this the expensive way: a soak run had four sends fail, my harness recorded &lt;code&gt;send_ok = 0&lt;/code&gt; and threw the error away, and connection reset, timeout and non-2xx are now permanently indistinguishable for those four events. The fix took ten minutes. The data is unrecoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconciliation: seven outcomes, not two
&lt;/h2&gt;

&lt;p&gt;For each fired event, count receipts carrying its &lt;code&gt;run_id&lt;/code&gt; and compare with what the workload expected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observed&lt;/th&gt;
&lt;th&gt;Classification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;receipts == expected&lt;/td&gt;
&lt;td&gt;success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0 receipts, event was accepted&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;missed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0 &amp;lt; receipts &amp;lt; expected&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;partial&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;receipts &amp;gt; expected&lt;/td&gt;
&lt;td&gt;duplicate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0 receipts, event was refused at send&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;rejected at send&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0 receipts, workload expected 0&lt;/td&gt;
&lt;td&gt;filtered (correct)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;receipts &amp;gt; 0, workload expected 0&lt;/td&gt;
&lt;td&gt;filter leak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rate uses two of these. Publish all seven anyway, because the other five are how a reader checks that the two mean what you say they mean. A &lt;code&gt;filtered&lt;/code&gt; count that is suspiciously high, or a &lt;code&gt;filter leak&lt;/code&gt; that is nonzero, tells you the workload is not doing what you think it is, and your rate is measuring something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exclusion that makes the number honest
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        missed + partial
SFR = ──────────────────────────────────────
      runs accepted and expected to produce output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Events refused at send are excluded from the numerator and from the denominator, and reported separately.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is counter-intuitive the first time. A refused send is a failure; why not count it?&lt;/p&gt;

&lt;p&gt;Because your sender already knows. A non-2xx, a timeout, a dropped connection: the caller sees it at the moment of sending, can log it, alert on it, retry it. No run was created. Nothing is hidden. That is the exact opposite of the property being measured.&lt;/p&gt;

&lt;p&gt;Mixing them gives you a number that cannot distinguish "the platform told you and you can retry" from "the platform didn't tell you and the data is gone".&lt;/p&gt;

&lt;p&gt;A worked example from my own ledger. A five-hour run of 2,880 events: 4 refused at send, 0 accepted-then-lost. The conformant report is &lt;strong&gt;0 silent failures in 2,876&lt;/strong&gt;, with 4 rejections reported alongside. Writing "4 failures in 2,880" is wrong in both directions at once: it inflates the rate with events the sender saw immediately, and it hides that nothing accepted was lost.&lt;/p&gt;

&lt;p&gt;It is also the rule that stops the metric being gamed. Without it, any inconvenient failure can be argued into a different bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  The statistics, briefly, because this is where it usually goes wrong
&lt;/h2&gt;

&lt;p&gt;Silent failure rates live near zero, which is exactly where the tools people reach for fail quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the Wilson score interval, not the normal approximation.&lt;/strong&gt; The normal interval is &lt;code&gt;p ± z·sqrt(p(1-p)/n)&lt;/code&gt;, and at &lt;code&gt;k = 0&lt;/code&gt; that collapses to zero width. It will report &lt;code&gt;0% ± 0%&lt;/code&gt; from 40 runs and present it as conservative. It is not conservative, it is wrong, and zero observed failures is the case this metric spends most of its life in.&lt;/p&gt;

&lt;p&gt;Wilson, for &lt;code&gt;k&lt;/code&gt; failures in &lt;code&gt;n&lt;/code&gt; runs at &lt;code&gt;z = 1.96&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          p + z²/2n                    z·sqrt( p(1-p)/n + z²/4n² )
centre = ───────────      half-width = ───────────────────────────
          1 + z²/n                              1 + z²/n

p = k/n, interval = [centre - half, centre + half] clamped to [0, 1]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero failures in 40 runs gives an upper bound near 7%. Zero in 4,000 gives well under 0.1%. Both are "0%". They are not the same claim, and publishing both as "0%" misleads by omission.&lt;/p&gt;

&lt;p&gt;Two consequences that surprise people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A wider interval is not a worse system.&lt;/strong&gt; If two things both show zero and one bound is wider, the difference is how much evidence exists, not how often each failed. If your samples are unequal, say so explicitly, because a reader compressing your table will otherwise supply the comparison themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero is never proven, only bounded.&lt;/strong&gt; More runs tighten the bound. Nothing ever establishes that the true rate is zero. A report describing its own zero as "confirmed" has said something false.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reporting: the qualifiers travel with the number
&lt;/h2&gt;

&lt;p&gt;Every published rate carries its denominator, its interval, its as-of date and its workload &lt;strong&gt;in the same sentence as the number&lt;/strong&gt;. Not in a footnote, not on a linked methodology page.&lt;/p&gt;

&lt;p&gt;This sounds pedantic until you follow a figure through extraction: table into summary, summary into quotation, quotation into someone's slide. Each step drops whatever was not adjacent to the number. The only qualifier that survives is the one inside the sentence.&lt;/p&gt;

&lt;p&gt;One more that nobody does: &lt;strong&gt;say whether the figure is alive.&lt;/strong&gt; Either it is refreshed, and how often, or it is a one-off snapshot that will not be updated. A one-off measurement is completely legitimate. A one-off in the present tense two years later is not, and the difference is a single sentence the publisher could have written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conformance, and what this deliberately is not
&lt;/h2&gt;

&lt;p&gt;Three self-declared levels. &lt;strong&gt;L1&lt;/strong&gt; needs no harness, no paid plans and no experiment: compute the rate from your own operational data and report it with the rules above. &lt;strong&gt;L2&lt;/strong&gt; adds a controlled measurement with both endpoints under your control. &lt;strong&gt;L3&lt;/strong&gt; adds three edge probes: destination outage, success-wrapped failure, sustained load.&lt;/p&gt;

&lt;p&gt;L1's bar is reporting discipline rather than effort, which is deliberate. It is a low bar in work and a surprisingly high one in practice: most published failure rates fail it on the reporting rules alone, because they arrive without a denominator.&lt;/p&gt;

&lt;p&gt;What it is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a tool.&lt;/strong&gt; Nothing to install. There is a reference implementation planned and unpublished, and I am not going to describe it as released while it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not audited.&lt;/strong&gt; No certifying body, no registry, nobody reviews claims including me. It only works because every requirement is something a reader can look for in your report and fail to find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not finished.&lt;/strong&gt; One section is explicitly reserved, with the reason written in place: the requirement that belongs there is drawn from a single observation that did not recur, and a rule from n=1 is a rule from first principles with an anecdote attached.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Spec: &lt;a href="https://github.com/benchtruth/sfr-spec" rel="noopener noreferrer"&gt;github.com/benchtruth/sfr-spec&lt;/a&gt; (v1.0, CC BY 4.0, with a machine-readable &lt;code&gt;sfr-v1.json&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Readable version and the measurements behind it: &lt;a href="https://benchtruth.com/spec" rel="noopener noreferrer"&gt;benchtruth.com/spec&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The three L3 probes, run and published: &lt;a href="https://benchtruth.com/webhook-retry-semantics" rel="noopener noreferrer"&gt;destination outage&lt;/a&gt;, &lt;a href="https://benchtruth.com/200-ok-error-payload" rel="noopener noreferrer"&gt;200 OK carrying an error&lt;/a&gt;, &lt;a href="https://benchtruth.com/self-hosted-n8n-under-load" rel="noopener noreferrer"&gt;sustained load&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Raw per-run data, CC BY 4.0: &lt;a href="https://benchtruth.com/reliability" rel="noopener noreferrer"&gt;benchtruth.com/reliability&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exclusion rule is the part I would most like someone to attack, because if it is wrong then every number I have published is wrong with it.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I load-tested self-hosted n8n on a $0 VM for 5 hours. The worst latency came from apt.</title>
      <dc:creator>Hao</dc:creator>
      <pubDate>Sun, 06 Sep 2026 11:41:08 +0000</pubDate>
      <link>https://dev.to/edifierxuhao/i-load-tested-self-hosted-n8n-on-a-0-vm-for-5-hours-the-worst-latency-came-from-apt-40c1</link>
      <guid>https://dev.to/edifierxuhao/i-load-tested-self-hosted-n8n-on-a-0-vm-for-5-hours-the-worst-latency-came-from-apt-40c1</guid>
      <description>&lt;p&gt;I run a benchmark harness that measures how often automation platforms silently drop webhook events. The self-hosted part of it lives on a Google Cloud e2-micro: 1 GB RAM, 0.25 vCPU baseline, 2 GB swap, free tier, $0/month. It handles about 108 events a day, roughly 4.5 an hour, and has been up since July.&lt;/p&gt;

&lt;p&gt;Someone pointed out that the burst test I had published (10 events at once) says nothing about sustained load, which is true and is the better question. So I held one event every 5 seconds against it for 4 hours and 42 minutes. 2,880 events. 136x its normal rate, continuously, while sampling host telemetry every 15 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale caveat before anything else, because 136x sounds more impressive than it is:&lt;/strong&gt; 0.17 events/second is a long duration at a modest rate. This tests whether a small box degrades or leaks over hours. It is not a throughput benchmark and tells you nothing about 50/second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing broke
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fired                     2,880
accepted                  2,876
refused at send               4   (loud, client errored)
accepted then lost            0   (silent)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero lost in five hours. No memory leak, no progressive slowdown, no queue backing up. That's the boring headline and it's the one I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  It got faster, which I did not expect
&lt;/h2&gt;

&lt;p&gt;Same workflow, same box, compared against normal operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          median      p95        p99
normal, last 7d  (n=260)   782 ms   10,577 ms   16,937 ms
normal, last 30d (n=1120)  822 ms   10,322 ms   25,517 ms
under soak       (n=2876)  610 ms    1,673 ms    5,392 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tail improved &lt;strong&gt;6.3x&lt;/strong&gt; while load went up 136x, and it holds against 7, 14 and 30 day baselines, so it isn't a stale-comparison artifact.&lt;/p&gt;

&lt;p&gt;My explanation is cold path: at 4.5 events an hour the instance is idle essentially always, and every arriving request has to wake something up. Under a 5-second cadence nothing gets a chance to go cold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'm labelling that a hypothesis, not a finding.&lt;/strong&gt; The experiment that would confirm it is trivial (fire one event after six hours idle, compare) and I haven't run it. What I'm claiming is the table, not the reason.&lt;/p&gt;

&lt;p&gt;If it does hold, it's a mildly uncomfortable thought for capacity planning on low-traffic self-hosted services: the ugly p99 you see in production may be an idleness cost rather than a capacity cost, and adding load would improve it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then, at 06:30, a 56-second request
&lt;/h2&gt;

&lt;p&gt;Thirty-minute buckets across the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bucket   fired  refused  undeliv    p50      p95      worst
04:00      253       0       0     521      1728     32,831
04:30      319       0       0     581       796      4,032
05:00      308       4       0     608       782     13,100
05:30      319       0       0     579       783      2,445
06:00      314       0       0     590      2174      5,617
06:30      273       0       0     715      4727     56,726   &amp;lt;--
07:00      312       0       0     658      1595      4,778
07:30      317       0       0     612       818      2,985
08:00      289       0       0     680      2363     36,194
08:30      176       0       0     536       965      7,669
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;56.7 seconds. 93x the median for that bucket.&lt;/p&gt;

&lt;p&gt;My first hypothesis was an OOM restart, because container memory had dropped from 252 MiB to 88 MiB in a single sample, below where it started the run.&lt;/p&gt;

&lt;p&gt;That hypothesis was wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n8n | Up 7 weeks
restarts=0  started=2026-07-12  oomkilled=false  exit=0
host up 54 days, no OOM in dmesg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container never restarted. The memory reading fell because the kernel reclaimed page cache, which &lt;code&gt;docker stats&lt;/code&gt; counts as container memory.&lt;/p&gt;

&lt;p&gt;The journal had the real answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="err"&gt;06:04:38&lt;/span&gt;  &lt;span class="err"&gt;Starting&lt;/span&gt; &lt;span class="err"&gt;apt-news.service&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt; &lt;span class="err"&gt;Update&lt;/span&gt; &lt;span class="err"&gt;APT&lt;/span&gt; &lt;span class="err"&gt;News...&lt;/span&gt;
&lt;span class="err"&gt;06:04:39&lt;/span&gt;  &lt;span class="err"&gt;Starting&lt;/span&gt; &lt;span class="err"&gt;packagekit.service&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt; &lt;span class="err"&gt;PackageKit&lt;/span&gt; &lt;span class="err"&gt;Daemon...&lt;/span&gt;
&lt;span class="err"&gt;06:49:49&lt;/span&gt;  &lt;span class="err"&gt;packagekit.service:&lt;/span&gt; &lt;span class="err"&gt;Deactivated&lt;/span&gt; &lt;span class="err"&gt;successfully.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The machine's own package-metadata refresh, running for 45 minutes. &lt;code&gt;sar&lt;/code&gt; shows what it did to the disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;time    %user   %iowait   %idle   dirty pages (kB)
06:10    1.65     2.66     95.05      204
06:20    1.46     2.03     95.93      388
06:30    4.09    20.20     71.54   41,316
06:40    4.04    34.24     57.02      524
06:50    1.66    29.12     68.12      336
07:00    1.61     3.64     93.88      664
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I/O wait went from 2.7% to 34.2% for three quarters of an hour on a shared-core VM with a slow persistent disk, and the request tail went with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My 136x load did not drop a single event in five hours. The box's package manager produced a 56-second request.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth carrying away
&lt;/h2&gt;

&lt;p&gt;Look at the &lt;code&gt;%user&lt;/code&gt; column again during that window. It goes from 1.6% to 4%.&lt;/p&gt;

&lt;p&gt;And free memory &lt;em&gt;increased&lt;/em&gt;, from 317 MB to 396 MB, because the kernel was reclaiming cache under write pressure.&lt;/p&gt;

&lt;p&gt;So for the entire 45 minutes in which p99 was 56 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU said the machine was idle.&lt;/strong&gt; By CPU it &lt;em&gt;was&lt;/em&gt; idle. It was blocked on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory said the machine was improving.&lt;/strong&gt; An alert threshold on free memory would have fired in the good direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only iowait showed anything,&lt;/strong&gt; and iowait is not on most default dashboards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A standard CPU-and-RAM monitoring setup would have reported a perfectly healthy machine throughout.&lt;/p&gt;

&lt;p&gt;There's a load-average wrinkle in the same direction: I sampled a load of 2.72 at 06:36 alongside 4% user CPU, which reads as a contradiction until you remember Linux counts processes blocked in uninterruptible disk wait toward load average. The one metric that &lt;em&gt;did&lt;/em&gt; look alarming looked alarming for a reason most dashboards don't explain.&lt;/p&gt;

&lt;p&gt;I spend my time measuring failures that don't announce themselves, so I should have seen this coming, and I still assumed OOM first. The instrument was working correctly and reporting on the wrong layer. That's the same shape as the thing I built the harness to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I can't explain
&lt;/h2&gt;

&lt;p&gt;Four sends were refused, at 05:23:43, 05:23:58, 05:24:41 and 05:24:56. Sequences 825, 826, 829 and 830 failed while 827 and 828 succeeded between them, so it was flapping, not an outage window.&lt;/p&gt;

&lt;p&gt;Four sources for that moment, all quiet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU: 1.51% user, 3.07% iowait, 94.69% idle&lt;/li&gt;
&lt;li&gt;Network: 5.12 packets/s in, flat across the window&lt;/li&gt;
&lt;li&gt;Container memory: 257.9 MiB, steady&lt;/li&gt;
&lt;li&gt;The service's own container log for 05:20 to 05:30: &lt;strong&gt;empty&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't know why those four were refused.&lt;/p&gt;

&lt;p&gt;And I made it harder on myself, because my harness recorded &lt;em&gt;that&lt;/em&gt; each send failed but not &lt;em&gt;why&lt;/em&gt;: the error was caught and thrown away. Connection reset, timeout and a non-2xx response are indistinguishable in my own ledger for those four events. Fixed now, but it's a fair thing to hold against this run, and a decent argument for logging the error string on every failure path even when you're sure you'll never need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;One box, one config, one trivial workload (webhook in, one HTTP call out). Nothing here transfers to a heavy multi-step workflow or a queue-mode deployment. Duration test, not throughput. Single run on a single day, so the apt-news window is a scheduled event I happened to catch rather than a rate. And the cold-path explanation for the latency result is unproven.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you self-host on a small VM
&lt;/h2&gt;

&lt;p&gt;Three things I'd take from this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check your &lt;code&gt;apt-daily&lt;/code&gt;, &lt;code&gt;apt-news&lt;/code&gt; and &lt;code&gt;unattended-upgrades&lt;/code&gt; timers&lt;/strong&gt; before blaming your application for mystery latency spikes. On a shared-core instance with a network disk, package metadata refresh is a real workload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put iowait on the dashboard.&lt;/strong&gt; CPU and memory will both look fine through a disk stall, and memory will look like it's improving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small box is likely fine for sustained low-rate work.&lt;/strong&gt; It may even be faster busy than idle. The thing that hurt me was maintenance, not traffic.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Method, per-run data and the full bucket table: &lt;a href="https://benchtruth.com/self-hosted-n8n-under-load" rel="noopener noreferrer"&gt;https://benchtruth.com/self-hosted-n8n-under-load&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I publish per-run data for all of this and pay for my own subscriptions. No vendor pays me, and there are no affiliate links on the data pages.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>performance</category>
    </item>
    <item>
      <title>I asked ChatGPT for free status page tools. It didn't mention mine. Here's the Next.js + Supabase build behind that zero.</title>
      <dc:creator>Hao</dc:creator>
      <pubDate>Tue, 05 May 2026 06:38:16 +0000</pubDate>
      <link>https://dev.to/edifierxuhao/i-asked-chatgpt-for-free-status-page-tools-it-didnt-mention-mine-heres-the-nextjs-supabase-15ei</link>
      <guid>https://dev.to/edifierxuhao/i-asked-chatgpt-for-free-status-page-tools-it-didnt-mention-mine-heres-the-nextjs-supabase-15ei</guid>
      <description>&lt;p&gt;Twenty-four hours after I swapped Supabase Auth's default SMTP for Resend, my first real user signed up. I'll call him K. I emailed him three questions. He replied in 49 minutes: "make a better ui of admin so we can use." That sentence is now my Q2 roadmap. Three weeks into launch I have 3 users, 2 Google clicks, and zero mentions when you ask ChatGPT or Gemini for "free status page alternatives." This is what I shipped, and where I'm still wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built another status page (when there are already 15)
&lt;/h2&gt;

&lt;p&gt;I launched EurekaWrite last year. Every time I deployed, users DM'd me the same question: "is it down?" I'd check, reply, repeat. After the tenth round I wanted a public URL I could send instead of typing the same reply. So I looked at what existed.&lt;/p&gt;

&lt;p&gt;Statuspage.io is $29/month for a page that says "All Systems Operational." That's $348 a year for one indie project that earns nothing yet. Skip.&lt;/p&gt;

&lt;p&gt;Upptime is free and open source. I tried it. Three things to learn before I get a public URL: a YAML monitor schema, a GitHub Actions workflow file, and the repo-as-config mental model where every status update is a commit. For a side project I want to ship in an evening, that is two evenings of yak-shaving I do not want.&lt;/p&gt;

&lt;p&gt;So I built StatusPageBuddy. The pitch on the landing page is one line: "Skip the YAML, the GitHub Actions, and the $29/month bill." Type a name. Get a link. Share it. 60 seconds, free forever.&lt;/p&gt;

&lt;p&gt;One scope decision worth naming: SPB is the public-facing page, not the prober. It is not a monitoring tool. That is deliberate. The trade-off buys 60-second onboarding: no built-in HTTP checks, no alerting rules engine, no integrations tab. Those are the three things a monitoring product would ship on day one and the three things SPB deliberately does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fah33jq0o3rmvcjqb4sp1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fah33jq0o3rmvcjqb4sp1.png" alt="StatusPageBuddy landing hero — 60 seconds, free forever" width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Supabase default that cost me a month of signups
&lt;/h2&gt;

&lt;p&gt;The stack that gets to 60 seconds is small on purpose.&lt;/p&gt;

&lt;p&gt;The stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16.2.1 + React 19 on Vercel&lt;/li&gt;
&lt;li&gt;Supabase for Auth, Postgres, and RLS&lt;/li&gt;
&lt;li&gt;Resend for transactional email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part that cost me a month was a default I never thought to question.&lt;/p&gt;

&lt;p&gt;Supabase Auth ships with a built-in SMTP sender for confirmation emails. It is rate-limited to 3-4 messages per hour on the free tier, and the From address is on a shared Supabase domain. That combination did two things. It throttled my signup flow during the only window people were trying it, and it routed the rest to spam. I watched signups start the flow and never come back, and I assumed the product was the problem.&lt;/p&gt;

&lt;p&gt;I caught it by accident. I created a fresh test account from a Gmail address I never use, watched the email never arrive, checked spam, and there it was. I sent another. Same path. Then I read the Supabase docs page on production SMTP and found the line I had skimmed past during setup: the built-in sender is for development, not production.&lt;/p&gt;

&lt;p&gt;The fix took 20 minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a Resend account&lt;/li&gt;
&lt;li&gt;Verify a sending domain (one DNS record for SPF, one for DKIM)&lt;/li&gt;
&lt;li&gt;Paste the Resend SMTP credentials into Supabase Auth → SMTP Settings&lt;/li&gt;
&lt;li&gt;Flip the toggle from default to custom&lt;/li&gt;
&lt;li&gt;Send a test signup, watch it land in inbox not spam&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Twenty-four hours later, K. signed up. I had been live for almost a month.&lt;/p&gt;

&lt;p&gt;If you are on Supabase and you have not swapped the default SMTP, swap it first. It is not a scaling concern. It is a "your funnel is closed and you do not know it" concern.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzv1dr0erfx22ba5acufa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzv1dr0erfx22ba5acufa.png" alt="Supabase Auth SMTP settings — custom SMTP enabled via Resend" width="800" height="171"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the architecture piece. The SMTP fix reopened the funnel; RLS is what lets me sleep at night with multi-tenant data in one Postgres database.&lt;/p&gt;

&lt;p&gt;SPB is multi-tenant: a user can belong to multiple orgs via an &lt;code&gt;org_members&lt;/code&gt; join table, and every &lt;code&gt;status_pages&lt;/code&gt; row is scoped to an &lt;code&gt;org_id&lt;/code&gt;. The entire authorization layer for that table is one policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- status_pages: a user sees only the pages of orgs they belong to&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"org_isolation"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;status_pages&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;org_members&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every read, write, update, delete from any client goes through that filter. The route still exists (Next.js RSC fetch, API handler, whatever), but the route can't leak rows: the query just returns zero. The counterfactual is the &lt;code&gt;if (user.org_id !== page.org_id) throw 403&lt;/code&gt; guard you'd otherwise sprinkle through every API handler and forget in exactly one of them. That forgotten one is the breach. RLS deletes the category.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three weeks of cold outreach, by the numbers
&lt;/h2&gt;

&lt;p&gt;Architecture closes the breach. It does not bring users. For that I had a spreadsheet and three weeks.&lt;/p&gt;

&lt;p&gt;Week 1: 17 cold touches, 0 signups. Comments on indie-dev threads, replies in Supabase Discord, two PRs to &lt;code&gt;awesome-*&lt;/code&gt; lists, one Twitter post from a 40-follower account. The Twitter post drew 2 impressions. That is the expected ceiling for a cold account with no graph, and it confirmed what I already suspected: Twitter is not a channel for me yet, it is a vanity surface I should stop touching until I have a reason to be there.&lt;/p&gt;

&lt;p&gt;Week 2: 8 cold touches, 3 signups. Fewer touches, tighter targeting — I dropped the broad "any indie thread" comments and only posted in places where someone had already named the problem ("what status page should I use," "alternatives to Statuspage.io"). The signups landed as K. on 4/25, R. on 4/28, M. on 5/1.&lt;/p&gt;

&lt;p&gt;The read on attribution: it is asset accumulation, not single-touch. The Supabase Discord #showcase post went up 4/22. K. signed up 4/25. Three days, not three minutes. The &lt;code&gt;awesome-status-pages&lt;/code&gt; PR (#194) merged 4/19, before W1 even started counting, and it almost certainly fed R. and M. later through SEO. Neither of them came from anything I did the day they signed up. They came from work that had been sitting on someone else's page for a week or two.&lt;/p&gt;

&lt;p&gt;The pattern: ecosystem PRs (&lt;code&gt;awesome-status-pages&lt;/code&gt; #194, plus a second PR to &lt;code&gt;awesome-selfhosted&lt;/code&gt; queued for review) and GitHub Issues where someone had already typed the buying question. Twitter stays off the list until I have a reason to be there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fepev7wo5rxlaxoz7puu9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fepev7wo5rxlaxoz7puu9.png" alt="Outreach tracker — Week 1 (17 touches, 0 signups) vs Week 2 (8 touches, 3 signups)" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first-user moment — why feedback in under 50 minutes changed the roadmap
&lt;/h2&gt;

&lt;p&gt;I gave K. 24 hours to poke around, then sent three questions over email. No marketing, no "we'd love your thoughts," signed "Hao."&lt;/p&gt;

&lt;p&gt;He replied in 49 minutes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"make a better ui of admin so we can use"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the verbatim reply. One sentence, no punctuation, no hedge.&lt;/p&gt;

&lt;p&gt;I had been planning a 12-question survey. I scrapped it. One sentence from a real user beats a survey because it tells you which bottleneck you are actually staring at. I had been optimizing acquisition — outreach lists, PR titles, Discord threads. K. told me the wall was activation, and inside activation it was the admin UI specifically. He had signed up. He had gotten in. He could not get out the other side with a working page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you want more posts like this — same numbers, smaller doses, sent Sundays — subscribe at &lt;a href="https://www.statuspagebuddy.com/subscribe" rel="noopener noreferrer"&gt;statuspagebuddy.com/subscribe&lt;/a&gt;. One email a week.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The follow-up I sent the next day, 4/28, did one thing: it forced a choice. "Which screen is the worst right now: components, incidents, or settings? Send a screenshot of the one that frustrated you most." Three options, one ask, one attachment. That is the shape that turned a free-form sentence into shippable scope. The trap with "tell me more" is that the user has to do the structuring work for you, and they will not. Give them the menu.&lt;/p&gt;

&lt;p&gt;The reusable shape of the email, in case it helps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Three questions, max.&lt;/li&gt;
&lt;li&gt;One of them forces a choice between named options you already know exist.&lt;/li&gt;
&lt;li&gt;One asks for an artifact (screenshot, link, snippet) so the reply has weight.&lt;/li&gt;
&lt;li&gt;Sign with your first name, not the company.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The funnel gap — when your spec is the bug
&lt;/h2&gt;

&lt;p&gt;K. told me activation was broken. R. showed me the data side of the same wall.&lt;/p&gt;

&lt;p&gt;R. signed up 4/28. He created an org. He never built a page. The &lt;code&gt;status_pages&lt;/code&gt; table for his &lt;code&gt;org_id&lt;/code&gt; stayed empty.&lt;/p&gt;

&lt;p&gt;I had a cron job that was supposed to catch exactly this case. It runs nightly, finds users who signed up more than 48 hours ago and have not "activated," and queues a reminder email. It never fired for R.&lt;/p&gt;

&lt;p&gt;The reason is embarrassing in the way good bugs always are. The cron's predicate for "activated" was &lt;code&gt;has_org&lt;/code&gt;. R. had an org. The query, correctly, returned zero rows. The code did exactly what it was told. The instruction was wrong.&lt;/p&gt;

&lt;p&gt;This is not a bug. The compiler did not lie. The database did not corrupt. The spec was wrong, and the spec was wrong because at the time I wrote it I had no users, and "create an org" felt like the meaningful step. Of course it did. It was the last step in the onboarding flow I had built. From the inside it looks like the finish line. From the user's side, the finish line is a published page someone else can load in a browser.&lt;/p&gt;

&lt;p&gt;Fix: redefine activation as &lt;code&gt;has_at_least_one_published_page&lt;/code&gt;. Re-fire the reminder cron with the new predicate. The two clauses, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- old: activation = "user has at least one org"&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;org_members&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- new: activation = "user has at least one published page"&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;status_pages&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;org_members&lt;/span&gt; &lt;span class="n"&gt;om&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;om&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;om&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R. now sits in the queue for tonight's run.&lt;/p&gt;

&lt;p&gt;The takeaway I want to carry into the next product I build:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your activation predicate is a product decision disguised as a SQL clause.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The schema makes one predicate cheap to query. The user behavior you actually care about is almost always a different one. Anywhere those two diverge, your dashboards look fine and your funnel stays empty. Mine was off by one join.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ChatGPT and Gemini say about my product (nothing)
&lt;/h2&gt;

&lt;p&gt;Schema fixes are inside-the-box work. The next beat is whether anyone outside the box can find SPB at all.&lt;/p&gt;

&lt;p&gt;On 5/2 I ran the check I had been avoiding. ChatGPT first: "free statuspage alternatives for indie developers." Then the same prompt into Gemini.&lt;/p&gt;

&lt;p&gt;Neither mentioned StatusPageBuddy. Baseline = 0.&lt;/p&gt;

&lt;p&gt;Both named the obvious incumbents — Statuspage, Instatus, Better Stack, Upptime, a couple of self-hosted Docker projects. I tried a second prompt ("cheap status page for a side project, not Statuspage.io"). Same result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (May 2026):&lt;/strong&gt; I later wrote point-by-point comparisons for the main incumbents — see &lt;br&gt;
&lt;a href="https://www.statuspagebuddy.com/alternatives/statuspage-io" rel="noopener noreferrer"&gt;/alternatives/statuspage-io&lt;/a&gt;,&lt;a href="https://www.statuspagebuddy.com/alternatives/atlassian-statuspage" rel="noopener noreferrer"&gt;/alternatives/atlassian-statuspage&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://www.statuspagebuddy.com/alternatives/better-stack" rel="noopener noreferrer"&gt;/alternatives/better-stack&lt;/a&gt;, &lt;br&gt;
and &lt;a href="https://www.statuspagebuddy.com/alternatives/instatus" rel="noopener noreferrer"&gt;/alternatives/instatus&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is the data point most build-in-public posts skip, because a zero is embarrassing in a way a low number is not. A low number reads like progress. A zero reads like absence. But LLMs are now a real discovery surface. When an indie dev asks for a tool today, a meaningful share of them ask a model first and Google second. A zero baseline is where the work begins.&lt;/p&gt;

&lt;p&gt;What I'm doing about it, in three lanes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ecosystem PRs to &lt;code&gt;awesome-*&lt;/code&gt; lists for permanent backlinks that crawlers and training pipelines both eventually find. Next on the queue: &lt;code&gt;awesome-selfhosted&lt;/code&gt; and &lt;code&gt;awesome-sre&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One weekly long-form post (this is the first) on dev.to and IndieHackers, canonical on the SPB blog.&lt;/li&gt;
&lt;li&gt;One Beehiiv issue per week, short and numbers-forward. First issue title: "Week 3: 3 users, 2 Google clicks, 0 AI mentions."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One PR, one post, one issue per week. I picked twelve weeks because that's one full GSC reporting window plus a buffer — long enough that "no movement" means the strategy is wrong, short enough that I'll still care. The lane I expect to fail first is the awesome-list PRs: most of those repos are unmaintained, and a merged PR is not a guaranteed crawl.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvquwu5fzdq79gi2090ba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvquwu5fzdq79gi2090ba.png" alt="ChatGPT and Gemini answers to " width="800" height="719"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO baseline — the search numbers nobody publishes
&lt;/h2&gt;

&lt;p&gt;The older surface is search. Vercel Analytics, week of 4/25–5/2: 28 unique visitors, down 45% week-over-week. 120 page views. 68% bounce rate.&lt;/p&gt;

&lt;p&gt;External referrers across the whole week, all sources combined: Google 2, Bing 1, GitHub 1. Four real external clicks in seven days. Everything else was direct or me.&lt;/p&gt;

&lt;p&gt;Search Console, three-month rolling total: 2 clicks, 13 impressions, 15.4% CTR, average position 2.6.&lt;/p&gt;

&lt;p&gt;Read that last line again. Average position 2.6. When SPB shows up at all, it ranks third on the page. The CTR is fine. The ranking is fine. The problem is that SPB shows up on thirteen queries in three months, total. Nobody is typing the queries I'd rank for, because the queries I'd rank for are queries nobody types.&lt;/p&gt;

&lt;p&gt;The takeaway is uncomfortable: SEO ranking is not the bottleneck for a product like this in week three. Indexed surface area is. There are not enough pages on the open web that mention SPB for Google to surface, and not enough pages that mention SPB inside any LLM's training cut for it to recall the name. That is the same problem the AI baseline points at, from the other side. Both fixes look like the same fix.&lt;/p&gt;

&lt;p&gt;This is roughly what week three looks like for any indie product without an existing audience. The interesting question is not whether the numbers are small. The interesting question is what the curve does between week three and week twelve.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16n167u8ojw67qgltz9s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16n167u8ojw67qgltz9s.png" alt="Search Console 3-month summary — 2 clicks, 13 impressions, 15.4% CTR, position 2.6" width="799" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm shipping next
&lt;/h2&gt;

&lt;p&gt;Three things that move the curve, in order.&lt;/p&gt;

&lt;p&gt;First, the admin UX overhaul K. asked for. Broken into three sub-tasks I can ship one per week: components screen, incidents screen, settings screen. The components screen is first because that is the one he sent the screenshot of.&lt;/p&gt;

&lt;p&gt;Second, the activation predicate fix from the funnel-gap section. Ship the new predicate, re-fire the reminder cron with R. in the queue, then leave it running so any future "org-only" user gets caught the same night.&lt;/p&gt;

&lt;p&gt;Third, a one-click "create demo page" on first org-create. The funnel gap was a missing reminder, but the deeper gap was that creating a page felt like a separate decision after creating an org. If the org-create handler also writes a draft page row, the activation predicate flips on the same click. That kills the same-shaped gap at the source instead of catching it on a cron 48 hours later.&lt;/p&gt;

&lt;p&gt;What would make the next post worth writing, as a threshold, not a scorecard: a non-zero AI mention, a second activated user, and the first 5 newsletter subs. If none of those move, I'll publish the same skeleton with the same zeros and write about why none of them moved. That is also a post.&lt;/p&gt;




&lt;p&gt;The newsletter is the main thing I'm asking for. Subscribe at &lt;a href="https://www.statuspagebuddy.com/subscribe" rel="noopener noreferrer"&gt;statuspagebuddy.com/subscribe&lt;/a&gt;. This Sunday: the W3 outreach data, whether the admin UX rewrite moved activation, and whether anything I shipped this week showed up in ChatGPT. One email a week. Hit reply.&lt;/p&gt;

&lt;p&gt;If you actually need a status page, &lt;a href="https://www.statuspagebuddy.com" rel="noopener noreferrer"&gt;statuspagebuddy.com&lt;/a&gt; is free forever for indie projects. Type a name, share a link. If something breaks, email me — same Hao.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>supabase</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
