<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ahoj Metrics]]></title><description><![CDATA[Ahoj Metrics]]></description><link>https://blog.ahojmetrics.com</link><generator>RSS for Node</generator><lastBuildDate>Thu, 09 Apr 2026 18:07:37 GMT</lastBuildDate><atom:link href="https://blog.ahojmetrics.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your Lighthouse Score Is Only Half the Story]]></title><description><![CDATA[A Lighthouse score of 95 feels great. Until you check what your actual users experience and find that 40% of them are getting a Poor LCP.
How? Because Lighthouse runs in a controlled environment. Fixe]]></description><link>https://blog.ahojmetrics.com/your-lighthouse-score-is-only-half-the-story</link><guid isPermaLink="true">https://blog.ahojmetrics.com/your-lighthouse-score-is-only-half-the-story</guid><category><![CDATA[Web Perf]]></category><category><![CDATA[Lighthouse]]></category><category><![CDATA[corewebvitals]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[Yuri Tománek]]></dc:creator><pubDate>Sun, 22 Feb 2026 04:21:42 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/69905bd4195f3a483587495d/2736385d-a360-4c29-8112-1396c431a576.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A Lighthouse score of 95 feels great. Until you check what your actual users experience and find that 40% of them are getting a Poor LCP.</p>
<p>How? Because Lighthouse runs in a controlled environment. Fixed CPU, fixed network, no browser extensions, cold cache. Your real users are on old Android phones, congested Wi-Fi, with 12 Chrome extensions installed. The test and reality can be very different.</p>
<p>We just shipped <strong>Field Data</strong> in Ahoj Metrics to close that gap. You can now look up real Chrome user experience data for any domain or URL, right alongside your Lighthouse audits.</p>
<h2>What Is Field Data?</h2>
<p>The data comes from Google's Chrome User Experience Report (CrUX). It's an aggregated, anonymised dataset of real performance timings collected from Chrome users who have opted in to sharing usage statistics.</p>
<p>When someone visits your site in Chrome, their browser quietly measures how long things take to load, how quickly the page responds to clicks, and how much the layout shifts around. Google aggregates this data across all opted-in Chrome users and makes it available through the CrUX API.</p>
<p>A few important details about how CrUX works:</p>
<p><strong>28-day rolling window.</strong> The data represents the last 28 days of real user visits. No single bad day can spike the numbers. No single good day can hide persistent problems.</p>
<p><strong>75th percentile (p75).</strong> The reported value isn't the average. It's the experience of someone at the 75th percentile, meaning 75% of your visitors had a better experience than this number, and 25% had a worse one. This is intentional. Google wants you to optimize for the tail, not the middle.</p>
<p><strong>Good / Needs Improvement / Poor distribution.</strong> Every page load gets classified against Google's thresholds. You can see what percentage of your users fall into each bucket. A site might have 80% Good, 12% Needs Improvement, and 8% Poor for LCP. That distribution tells you more than any single number.</p>
<h2>Lab Data vs Field Data</h2>
<p>This is the core concept. Both are useful. Neither is complete on its own.</p>
<p><strong>Lab data (Lighthouse)</strong> tests your site in a controlled environment. Same CPU, same network throttling, same browser config, every time. It's reproducible. It's great for finding issues, comparing before/after a deployment, and running automated tests in CI/CD. But it's synthetic. It doesn't represent any real user.</p>
<p><strong>Field data (CrUX)</strong> measures what your actual visitors experience. Real devices, real networks, real browser configurations. It's messy and variable, but it's the truth. It's also what Google uses for Core Web Vitals in Search ranking.</p>
<p>Here's where it gets interesting: these two numbers can disagree significantly.</p>
<p>A site might score 68 on Lighthouse (worrying) but show 85% Good LCP in CrUX (fine in practice). Why? Maybe most of your users are on fast connections with warm caches, so the real experience is better than what the lab predicts.</p>
<p>Or the reverse: a Lighthouse score of 92 (looks great) but only 55% Good LCP in CrUX (a real problem). Maybe your audience skews toward mobile users in regions with slower connectivity, and the lab test doesn't capture that.</p>
<p>Neither number is "right." Lab data tells you what's wrong. Field data tells you the impact. You need both to make good decisions about where to spend your optimization time.</p>
<h2>The Five Metrics</h2>
<p>Field Data in Ahoj Metrics shows five metrics:</p>
<p><strong>LCP (Largest Contentful Paint)</strong> measures how quickly the main content loads. This is usually the hero image, a large heading, or a video thumbnail. Google considers under 2.5 seconds "Good."</p>
<p><strong>INP (Interaction to Next Paint)</strong> measures how responsive the page is to user input. When someone taps a button or clicks a link, how long before something visibly happens? Under 200ms is "Good." INP replaced FID (First Input Delay) as a Core Web Vital in 2024.</p>
<p><strong>CLS (Cumulative Layout Shift)</strong> measures how much the layout jumps around while loading. You know when you're about to tap a button and an ad loads above it, pushing everything down? That's layout shift. Under 0.10 is "Good."</p>
<p><strong>FCP (First Contentful Paint)</strong> measures how quickly the first piece of content appears. Not the main content (that's LCP), just anything: text, an image, the background color. Under 1.8 seconds is "Good."</p>
<p><strong>TTFB (Time to First Byte)</strong> measures how quickly the server responds to the browser's request. Under 800ms is "Good."</p>
<p>LCP, INP, and CLS are Google's three Core Web Vitals. These are the metrics that directly feed into Google's search ranking signals. If you can only focus on three things, focus on these.</p>
<h2>How to Use It</h2>
<p>Go to <strong>Field Data</strong> in the Ahoj Metrics sidebar. Enter any domain (like <code>https://stripe.com</code>) or a specific URL. Hit <strong>Look Up Field Data</strong>.</p>
<p>You'll see the p75 value and the Good/Needs Improvement/Poor distribution for all five metrics. Instant results, no audit credits used.</p>
<p>A few things to know:</p>
<p><strong>It works for any public site.</strong> You can look up your competitors, your clients, or any site you're curious about. The data is public.</p>
<p><strong>Not every URL has data.</strong> CrUX needs a meaningful amount of Chrome traffic to generate a record. If you look up an internal tool, a brand new site, or a low-traffic page, Google won't have data for it. You'll see a clear message when that happens. Origin-level lookups (the whole domain) are more likely to have data than individual URLs.</p>
<p><strong>It's available to all users.</strong> Free tier, paid plans, everyone. Field Data lookups don't count against your audit quota. The CrUX API is free from Google, and we saw no reason to gate it.</p>
<h2>How This Changes Your Workflow</h2>
<p>Before, an Ahoj Metrics workflow looked like this:</p>
<ol>
<li><p>Run Lighthouse audit from multiple regions</p>
</li>
<li><p>See scores and recommendations</p>
</li>
<li><p>Fix issues</p>
</li>
<li><p>Run another audit to verify</p>
</li>
</ol>
<p>Now it looks like this:</p>
<ol>
<li><p>Check Field Data for a baseline of what real users experience</p>
</li>
<li><p>Run Lighthouse audit from multiple regions to find specific issues</p>
</li>
<li><p>Fix issues</p>
</li>
<li><p>Run another audit to verify the fix</p>
</li>
<li><p>Wait for field data to update (28-day rolling window) to confirm the real-world impact</p>
</li>
</ol>
<p>Field data gives you the "why" behind your optimization work. You're not fixing things because a synthetic test says so. You're fixing things because 30% of your real users are getting a Poor LCP.</p>
<h2>Why Not Just Use PageSpeed Insights?</h2>
<p>Google's PageSpeed Insights already shows CrUX data. It's free and it works. So why look at it in Ahoj Metrics?</p>
<p>Context. In PSI, field data lives on Google's website, separate from everything else. You look up a URL, see the numbers, close the tab. In Ahoj Metrics, field data lives next to your Lighthouse audits, your monitors, and your historical data. You can see how your lab scores compare to real-world experience for the same site, in the same tool, without switching between tabs.</p>
<p>PSI also doesn't save history, doesn't compare across sites, and doesn't integrate into a monitoring workflow. It's a snapshot tool. Ahoj Metrics is trying to be the place where all your performance data lives together.</p>
<h2>Technical Details</h2>
<p>For anyone curious about the implementation:</p>
<p>We built a thin Ruby wrapper around the CrUX API (<code>ahojmetrics/crux-api</code>). Results are cached server-side for 12 hours using Solid Cache (PostgreSQL-backed, same as the rest of our infrastructure). Repeat lookups for the same URL are instant.</p>
<p>The API response from Google is verbose. Metric names are long (<code>largest_contentful_paint</code>), CLS comes back as a string float, and the structure is nested. Our serializer normalizes everything into a clean JSON shape with short keys (<code>lcp</code>, <code>inp</code>, <code>cls</code>) that the frontend can work with easily.</p>
<p>Authentication is the same as every other Ahoj endpoint. Standard JWT/session auth, no separate API key needed.</p>
<h2>What's Next</h2>
<p>Field Data is a lookup tool today. You search for a URL and see the current CrUX data. We're thinking about:</p>
<ul>
<li><p><strong>Historical field data tracking.</strong> Store CrUX snapshots over time so you can see trends, not just the current 28-day window.</p>
</li>
<li><p><strong>Field data alongside monitors.</strong> When your automated Lighthouse monitor runs, also pull the CrUX data for that URL and display them together.</p>
</li>
<li><p><strong>Field vs lab comparison view.</strong> A side-by-side showing your Lighthouse lab metrics and CrUX field metrics for the same URL, highlighting where they agree and where they diverge.</p>
</li>
</ul>
<p>If any of those would be particularly useful to you, I'd love to hear about it.</p>
<h2>Try It</h2>
<p>Sign in to <a href="https://ahojmetrics.com">Ahoj Metrics</a> and go to Field Data in the sidebar. Look up your own site, look up your competitors, look up anything. No credits used, no limits.</p>
<p>If you don't have an account, the free tier gives you 20 Lighthouse audits per month plus unlimited Field Data lookups.</p>
<hr />
<p><em>Ahoj Metrics is a performance monitoring tool that runs Lighthouse audits from 18 global regions and now shows real Chrome user experience data via CrUX. Built with Rails 8, Solid Queue, and Fly.io.</em></p>
]]></content:encoded></item><item><title><![CDATA[How We Run Lighthouse from 18 Regions in Under 2 Minutes]]></title><description><![CDATA[Most performance monitoring tools test your site from one location, or run tests sequentially across regions. That means testing from 18 locations can take 20+ minutes.
We needed something faster. Ahoj Metrics tests from 18 global regions simultaneou...]]></description><link>https://blog.ahojmetrics.com/how-we-run-lighthouse-from-18-regions-in-under-2-minutes</link><guid isPermaLink="true">https://blog.ahojmetrics.com/how-we-run-lighthouse-from-18-regions-in-under-2-minutes</guid><category><![CDATA[Rails]]></category><category><![CDATA[fly.io]]></category><category><![CDATA[Lighthouse]]></category><category><![CDATA[Web Perf]]></category><category><![CDATA[infrastructure]]></category><dc:creator><![CDATA[Yuri Tománek]]></dc:creator><pubDate>Sat, 14 Feb 2026 11:58:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/V4DKsVkXIcs/upload/3aca5101e7619b19bdb9eba533736d1c.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most performance monitoring tools test your site from one location, or run tests sequentially across regions. That means testing from 18 locations can take 20+ minutes.</p>
<p>We needed something faster. Ahoj Metrics tests from 18 global regions simultaneously in about 2 minutes. Here's how.</p>
<h2 id="heading-the-architecture">The Architecture</h2>
<p>The core idea is simple: don't keep workers running. Spawn them on demand, run the test, destroy them.</p>
<p>We use <a target="_blank" href="https://fly.io/docs/machines/">Fly.io's Machines API</a> to create ephemeral containers in specific regions. Each container runs a single Lighthouse audit, sends the results back via webhook, and destroys itself.</p>
<p>Here's how a request flows through the system:</p>
<pre><code class="lang-mermaid">sequenceDiagram
    actor User
    participant App as Rails App
    participant DB as PostgreSQL
    participant SQ as Solid Queue
    participant Fly as Fly API
    participant W1 as Worker (Sydney)
    participant W2 as Worker (London)
    participant W3 as Worker (Tokyo)

    User-&gt;&gt;App: Run Audit (3 regions)
    App-&gt;&gt;DB: Create ReportRequest
    App-&gt;&gt;DB: Create 3 Report records

    App-&gt;&gt;SQ: Enqueue spawn jobs

    par Spawn workers simultaneously
        SQ-&gt;&gt;Fly: POST /machines (Sydney)
        Fly-&gt;&gt;W1: Boot container
        SQ-&gt;&gt;Fly: POST /machines (London)
        Fly-&gt;&gt;W2: Boot container
        SQ-&gt;&gt;Fly: POST /machines (Tokyo)
        Fly-&gt;&gt;W3: Boot container
    end

    par Run Lighthouse in parallel
        W1-&gt;&gt;W1: Run Lighthouse (~2 min)
        W2-&gt;&gt;W2: Run Lighthouse (~2 min)
        W3-&gt;&gt;W3: Run Lighthouse (~2 min)
    end

    par Report results back
        W1-&gt;&gt;App: POST results (webhook)
        Note right of W1: Auto-destroys
        W2-&gt;&gt;App: POST results (webhook)
        Note right of W2: Auto-destroys
        W3-&gt;&gt;App: POST results (webhook)
        Note right of W3: Auto-destroys
    end

    App-&gt;&gt;DB: Update Reports
    App-&gt;&gt;DB: Aggregate stats on ReportRequest
    App-&gt;&gt;User: Dashboard updated
</code></pre>
<p>The key design decision: <strong>one audit = one ReportRequest</strong>, regardless of how many regions you test. Test from 1 region or 18 - it's the same user action.</p>
<h2 id="heading-spawning-machines-with-the-flyio-api">Spawning Machines with the Fly.io API</h2>
<p>Here's the actual code that creates a machine in a specific region:</p>
<pre><code class="lang-ruby"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">FlyMachinesService</span></span>
  API_BASE_URL = <span class="hljs-string">"https://api.machines.dev/v1"</span>

  <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">self</span>.<span class="hljs-title">create_machine</span><span class="hljs-params">(<span class="hljs-symbol">region:</span>, <span class="hljs-symbol">env:</span>, <span class="hljs-symbol">app_name:</span>)</span></span>
    url = <span class="hljs-string">"<span class="hljs-subst">#{API_BASE_URL}</span>/apps/<span class="hljs-subst">#{app_name}</span>/machines"</span>

    body = {
      <span class="hljs-symbol">region:</span> region,
      <span class="hljs-symbol">config:</span> {
        <span class="hljs-symbol">image:</span> ENV.fetch(<span class="hljs-string">"WORKER_IMAGE"</span>, <span class="hljs-string">"registry.fly.io/am-worker:latest"</span>),
        <span class="hljs-symbol">size:</span> <span class="hljs-string">"performance-8x"</span>,
        <span class="hljs-symbol">auto_destroy:</span> <span class="hljs-literal">true</span>,
        <span class="hljs-symbol">restart:</span> { <span class="hljs-symbol">policy:</span> <span class="hljs-string">"no"</span> },
        <span class="hljs-symbol">stop_config:</span> {
          <span class="hljs-symbol">timeout:</span> <span class="hljs-string">"30s"</span>,
          <span class="hljs-symbol">signal:</span> <span class="hljs-string">"SIGTERM"</span>
        },
        <span class="hljs-symbol">env:</span> env,
        <span class="hljs-symbol">services:</span> []
      }
    }

    response = HTTParty.post(
      url,
      <span class="hljs-symbol">headers:</span> headers,
      <span class="hljs-symbol">body:</span> body.to_json,
      <span class="hljs-symbol">timeout:</span> <span class="hljs-number">30</span>
    )

    <span class="hljs-keyword">if</span> response.success?
      Response.new(<span class="hljs-symbol">success:</span> <span class="hljs-literal">true</span>, <span class="hljs-symbol">data:</span> response.parsed_response)
    <span class="hljs-keyword">else</span>
      Response.new(
        <span class="hljs-symbol">success:</span> <span class="hljs-literal">false</span>,
        <span class="hljs-symbol">error:</span> <span class="hljs-string">"API error: <span class="hljs-subst">#{response.code}</span> - <span class="hljs-subst">#{response.body}</span>"</span>
      )
    <span class="hljs-keyword">end</span>
  <span class="hljs-keyword">end</span>
<span class="hljs-keyword">end</span>
</code></pre>
<p>A few things worth noting:</p>
<p><strong><code>auto_destroy: true</code></strong> is the magic. The machine cleans itself up after the process exits. No lingering containers, no zombie workers, no cleanup cron jobs.</p>
<p><strong><code>performance-8x</code></strong> gives us 4 vCPU and 8GB RAM. Lighthouse is resource-hungry - it runs a full Chrome instance. Underpowered machines produce inconsistent scores because Chrome competes for CPU time. We tried smaller sizes and the variance was too high.</p>
<p><strong><code>restart: { policy: "no" }</code></strong> means if Lighthouse crashes, the machine just dies. We handle the failure on the Rails side by checking for timed-out reports.</p>
<p><strong><code>services: []</code></strong> means no public ports. The worker doesn't need to accept incoming traffic. It runs Lighthouse and POSTs results back to our API. That's it.</p>
<h2 id="heading-the-worker">The Worker</h2>
<p>Each Fly.io machine runs a Docker container that does roughly this:</p>
<ol>
<li>Read environment variables (target URL, callback URL, report ID)</li>
<li>Launch headless Chrome</li>
<li>Run Lighthouse audit</li>
<li>POST the JSON results back to the Rails API</li>
<li>Exit (machine auto-destroys)</li>
</ol>
<p>The callback is a simple webhook. The worker doesn't need to know anything about our database, user accounts, or billing. It just runs a test and reports back.</p>
<h2 id="heading-handling-results">Handling Results</h2>
<p>On the Rails side, each Report record tracks its own status:</p>
<pre><code class="lang-ruby"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ReportRequest</span> &lt; ApplicationRecord</span>
  has_many <span class="hljs-symbol">:reports</span>

  <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">check_completion!</span></span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">unless</span> reports.all?(&amp;<span class="hljs-symbol">:completed?</span>)

    update!(<span class="hljs-symbol">status:</span> <span class="hljs-string">"completed"</span>)
    update_cached_stats!
    check_monitor_alert <span class="hljs-keyword">if</span> site_monitor.present?
  <span class="hljs-keyword">end</span>
<span class="hljs-keyword">end</span>
</code></pre>
<p>When a worker POSTs results, the corresponding Report is updated. After each update, we check if all reports for the request are done. If so, we aggregate the results, calculate averages, and update the dashboard.</p>
<p>Each report is independent. If the Sydney worker fails but the other 17 succeed, you still get 17 results. The failed region shows as an error without blocking everything else.</p>
<h2 id="heading-cost-math">Cost Math</h2>
<p>This is the part that makes ephemeral workers compelling. Compare two approaches:</p>
<p><strong>Persistent workers (18 regions, always-on):</strong></p>
<ul>
<li>18 performance-8x machines running 24/7</li>
<li>Based on Fly.io's pricing calculator: ~$2,734/month</li>
<li>Mostly sitting idle waiting for audit requests</li>
</ul>
<p><strong>Ephemeral workers (our approach):</strong></p>
<ul>
<li>Machines run for ~2 minutes per audit</li>
<li>performance-8x costs roughly $0.0001344/second</li>
<li>One 18-region audit costs about $0.29</li>
<li>100 audits/month = ~$29</li>
</ul>
<p>At low volume, ephemeral is dramatically cheaper. The crossover point where persistent workers become more cost-effective is well beyond our current scale.</p>
<p>The tradeoff is cold start time. Each machine takes a few seconds to boot. For our use case (users expect a 1-2 minute wait anyway), that's invisible.</p>
<h2 id="heading-the-background-job-layer">The Background Job Layer</h2>
<p>We use Solid Queue (Rails 8's built-in job backend) for everything. No Redis, no Sidekiq.</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># config/recurring.yml</span>
<span class="hljs-attr">production:</span>
  <span class="hljs-attr">monitor_scheduler:</span>
    <span class="hljs-attr">class:</span> <span class="hljs-string">MonitorSchedulerJob</span>
    <span class="hljs-attr">queue:</span> <span class="hljs-string">default</span>
    <span class="hljs-attr">schedule:</span> <span class="hljs-string">every</span> <span class="hljs-string">minute</span>
</code></pre>
<p>The MonitorSchedulerJob runs every minute, checks which monitors are due for testing, and kicks off the Fly.io machine spawning. Monitor runs are background operations - they don't count toward the user's audit quota.</p>
<p>This keeps the architecture simple. One PostgreSQL database handles the queue (via Solid Queue), the application data, and the cache. No Redis to manage, no separate queue infrastructure to monitor.</p>
<h2 id="heading-what-we-learned">What We Learned</h2>
<p><strong>Lighthouse needs consistent resources.</strong> When we first used shared-cpu machines, scores would vary by 15-20 points between runs of the same URL. Bumping to performance-8x brought variance down to 2-3 points. The extra cost per audit is worth the consistency.</p>
<p><strong>Timeouts need multiple layers.</strong> We set timeouts at the HTTP level (30s for API calls), the machine level (stop_config timeout), and the application level (mark reports as failed after 5 minutes). Belt and suspenders.</p>
<p><strong>Region availability isn't guaranteed.</strong> Sometimes a Fly.io region is temporarily unavailable. We handle this gracefully - the report for that region shows an error, but the rest of the audit completes normally.</p>
<p><strong>Webhook delivery can fail.</strong> If our API is temporarily unreachable when the worker finishes, we lose the result. We're adding a retry mechanism and considering having workers write results to object storage as a fallback.</p>
<h2 id="heading-the-numbers">The Numbers</h2>
<p>After running this in production since January 2026:</p>
<ul>
<li>Average audit time: ~2 minutes (single region or all 18)</li>
<li>P95 audit time: ~3 minutes</li>
<li>Machine boot time: 3-8 seconds depending on region</li>
<li>Success rate: ~97% (3% are timeouts or region availability issues)</li>
<li>Cost per audit: $0.01-0.29 depending on regions selected</li>
</ul>
<h2 id="heading-try-it">Try It</h2>
<p>You can test this yourself at <a target="_blank" href="https://ahojmetrics.com">ahojmetrics.com</a>. Free tier gives you 20 audits/month - enough to see how your site performs from Sydney, Tokyo, Sao Paulo, London, and more.</p>
<p>If you have questions about the architecture, ask in the comments. Happy to go deeper on any part of this.</p>
<hr />
<p><em>Built with Rails 8.1, Solid Queue, Fly.io Machines API, and PostgreSQL. Frontend is React + TypeScript on Cloudflare Pages.</em></p>
]]></content:encoded></item></channel></rss>