<?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: Haimanot Getu</title>
    <description>The latest articles on DEV Community by Haimanot Getu (@haimanot_getu).</description>
    <link>https://dev.to/haimanot_getu</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%2F757610%2F7f82d2e4-e099-42b2-8bd9-e24c011f8dfd.jpg</url>
      <title>DEV Community: Haimanot Getu</title>
      <link>https://dev.to/haimanot_getu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haimanot_getu"/>
    <language>en</language>
    <item>
      <title>How Beaconmon Monitors Shopify Competitor Storefronts at Scale (Without Playwright)</title>
      <dc:creator>Haimanot Getu</dc:creator>
      <pubDate>Wed, 10 Jun 2026 06:16:42 +0000</pubDate>
      <link>https://dev.to/haimanot_getu/how-beaconmon-monitors-shopify-competitor-storefronts-at-scale-without-playwright-31nb</link>
      <guid>https://dev.to/haimanot_getu/how-beaconmon-monitors-shopify-competitor-storefronts-at-scale-without-playwright-31nb</guid>
      <description>&lt;p&gt;&lt;em&gt;A practical look at content monitoring at scale without Playwright, without scraping farms, and without setting a single VPS on fire.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The 2am Alert That Started This Post
&lt;/h2&gt;

&lt;p&gt;A skincare brand using Beaconmon woke up to an alert. A top competitor had quietly dropped prices across their bestselling serums by 18% and was running a sitewide free-shipping promo. No press release. No announcement. Just a Tuesday.&lt;/p&gt;

&lt;p&gt;Their team repriced and matched the shipping threshold before the competitor's paid ads started driving volume. They saw it because a background worker had fetched that competitor's storefront, extracted the right DOM nodes with cheerio, diffed the result against a 24-hour-old snapshot, and scored the change as high-significance.&lt;/p&gt;

&lt;p&gt;That is the whole product. Everything below is how we make it reliable at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;BullMQ workers fetch competitor HTML on a schedule&lt;/li&gt;
&lt;li&gt;cheerio extracts price, promo, and product-grid content using a ranked selector cascade&lt;/li&gt;
&lt;li&gt;Diffs are normalized, stored, and scored by rules first, AI second&lt;/li&gt;
&lt;li&gt;No Playwright, no headless Chrome, no scraping farm&lt;/li&gt;
&lt;li&gt;The lesson that mattered most: &lt;strong&gt;normalize before storage, or you will alert on whitespace forever&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Architecture in One Paragraph
&lt;/h2&gt;

&lt;p&gt;Every tracked competitor is a &lt;code&gt;Monitor&lt;/code&gt; record flagged &lt;code&gt;is_competitor: true&lt;/code&gt;. A BullMQ scheduler enqueues a &lt;code&gt;content-check&lt;/code&gt; job for each one on its configured interval (15 minutes on free, down to 5 on Growth). Workers pull jobs, fetch the page with &lt;code&gt;undici&lt;/code&gt;, parse with &lt;code&gt;cheerio&lt;/code&gt;, normalize the text, and compare against the last snapshot in Postgres. If anything changed, a second job scores the significance and fans out alerts.&lt;/p&gt;

&lt;p&gt;No Playwright. No headless Chrome. HTML-only, on purpose. A single VPS will not survive 3,000 concurrent Chromium processes, and the signal we care about (price text, promo copy, product grid content) almost always exists in the initial HTML response.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Map the Catalog Once, Then Leave It Alone
&lt;/h2&gt;

&lt;p&gt;Shopify exposes a public JSON endpoint for any store's product catalog. We fetch it once at setup time to build an internal product map:&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;type&lt;/span&gt; &lt;span class="nx"&gt;ProductMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Variant&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;priceRange&lt;/span&gt;&lt;span class="p"&gt;:&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That map is what lets us say "3,000 products" instead of "3,000 URLs." We do not poll that endpoint on every check cycle. It is the seed, not the heartbeat.&lt;/p&gt;

&lt;p&gt;The heartbeat is cheerio against the live HTML.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The Selector Cascade
&lt;/h2&gt;

&lt;p&gt;Shopify themes are not standardized, but they rhyme. Debut, Dawn, and most third-party themes share a small vocabulary of price-related class names. We try a ranked list of selectors and take the first match:&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;PRICE_SELECTORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.price__sale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.price-item--sale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.price__regular&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.price-item--regular&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.product-form__price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.product__price&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-product-price]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-price]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="price__"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="ProductPrice"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.price&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If none match, we fall back to &lt;code&gt;main&lt;/code&gt;. A fallback match is recorded but never marked as "detected," so a miss on the specific selector does not produce a false-positive event.&lt;/p&gt;

&lt;p&gt;That distinction matters. We separate &lt;em&gt;"we found the thing we wanted"&lt;/em&gt; from &lt;em&gt;"we tracked something and it changed."&lt;/em&gt; If you collapse those two states, your alert quality dies.&lt;/p&gt;

&lt;p&gt;The same pattern applies to announcement bars, collection grids, and sale pages. Each preset is a ranked selector list plus a fallback:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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;announcement-bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.announcement-bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.shopify-section--announcement-bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="AnnouncementBar"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-announcement-bar]&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;fallbackSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: The Actual Content Check
&lt;/h2&gt;

&lt;p&gt;The fetch and parse is about 30 lines. The important parts are normalization, and using &lt;code&gt;undici&lt;/code&gt; over &lt;code&gt;node-fetch&lt;/code&gt; for connection pooling at volume.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undici&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cheerio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headersTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bodyTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beaconmon/1.0 (+https://beaconmon.com)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html,application/xhtml+xml&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="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;selector&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawText&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t]&lt;/span&gt;&lt;span class="sr"&gt;+/g&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The normalization step matters more than it looks. Shopify themes render whitespace inconsistently across CDN edges and A/B test variants. Without collapsing horizontal whitespace per line and stripping blanks, you get false-positive diffs every few hours on high-traffic stores. With it, the diff is quiet until something actually changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Score What Changed
&lt;/h2&gt;

&lt;p&gt;Not all diffs are equal. A competitor updating their "About" copy is noise. A competitor dropping 18% off their hero SKU is a buying signal.&lt;/p&gt;

&lt;p&gt;We score in two layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules First (fast, free, zero latency)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_changed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;old&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;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out_of_stock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;back_in_stock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sale_started&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  AI Second (Growth and Scale plans only, fails open)
&lt;/h3&gt;

&lt;p&gt;The diff text goes to Claude with a short system prompt describing the scoring rubric. It returns &lt;code&gt;low&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, or &lt;code&gt;high&lt;/code&gt; with a 4-second hard timeout. If the model is unreachable or the plan does not include AI, the rule-based score is used. The system never blocks on the AI layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Data Actually Enables
&lt;/h2&gt;

&lt;p&gt;This is the part that matters to the stores using it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repricing with context, not fear.&lt;/strong&gt; When you see a competitor's price drop tagged &lt;code&gt;high&lt;/code&gt; at 2am, you have a diff, a timestamp, and the old price. You are not guessing. You are deciding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Promo calendar reconstruction.&lt;/strong&gt; Six weeks of announcement-bar snapshots tells you when a competitor runs their sales, how long they run, and what copy they use. That is a content calendar you did not have to build yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New-arrival velocity.&lt;/strong&gt; A collection-grid monitor tells you how fast a competitor is dropping product. Twice a week means real buying power or a production operation worth watching. Once a month means they are coast-clearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stock signal for sourcing.&lt;/strong&gt; Out-of-stock events on a competitor's bestsellers can indicate a supply chain gap. That is a window to step up your own inventory or run a targeted ad against their unmet demand.&lt;/p&gt;

&lt;p&gt;None of that requires crawling at scale or violating terms of service. It is the same HTML a browser would render, fetched politely with a declared user-agent, on a reasonable interval.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three Lessons I'd Tell My Past Self
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The selector cascade is the hard part, not the queue
&lt;/h3&gt;

&lt;p&gt;Shopify's theme ecosystem is large enough that there is no single correct selector. A ranked list with a named fallback, and a clear distinction between "matched" and "fell back," is the right model. Get this wrong and alert quality collapses.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Normalize before storage. Always.
&lt;/h3&gt;

&lt;p&gt;Storing raw HTML and diffing later sounds appealing until you realize CDN-injected attributes, nonce values in inline scripts, and whitespace drift will fire alerts on every check for a percentage of your monitors. Store the normalized text.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fail open everywhere
&lt;/h3&gt;

&lt;p&gt;The AI layer falls back. The selector cascade falls back. The check itself records the error type and moves on rather than retrying forever. A monitoring product that generates noise when its own dependencies hiccup is worse than useless.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap
&lt;/h2&gt;

&lt;p&gt;If you are building something similar, or you run a Shopify or WooCommerce store and want to see this in action, &lt;a href="https://beaconmon.com" rel="noopener noreferrer"&gt;Beaconmon&lt;/a&gt; is in early access. I am happy to talk architecture, selector strategies, or where the AI layer earns its keep versus where rules are enough.&lt;/p&gt;

&lt;p&gt;Find me at &lt;a href="https://dev.to/haimanot_getu"&gt;@haimanot_getu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>shopify</category>
    </item>
    <item>
      <title>I built a bootstrapped uptime and competitor intelligence SaaS for eCommerce</title>
      <dc:creator>Haimanot Getu</dc:creator>
      <pubDate>Mon, 08 Jun 2026 10:11:44 +0000</pubDate>
      <link>https://dev.to/haimanot_getu/i-built-a-bootstrapped-uptime-and-competitor-intelligence-saas-for-ecommerce-3e3k</link>
      <guid>https://dev.to/haimanot_getu/i-built-a-bootstrapped-uptime-and-competitor-intelligence-saas-for-ecommerce-3e3k</guid>
      <description>&lt;p&gt;I run a small side project called Beaconmon. It monitors websites for uptime, tracks competitor pricing and promotions, and sends intelligence digests to eCommerce store owners. Shopify and WooCommerce are the primary target.&lt;/p&gt;

&lt;p&gt;Here is how it is built and the decisions that shaped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Most uptime tools are overkill for a small eCommerce store. A Shopify merchant does not need Datadog. They need to know when their store goes down, when a competitor drops prices before a sale weekend, and when a checkout page breaks.&lt;/p&gt;

&lt;p&gt;The interesting constraint: competitor tracking is not just a nice-to-have. It is the primary reason someone would pay for this over a free tier of Pingdom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Next.js 15 (App Router) for the dashboard and the marketing site, combined in one app&lt;/li&gt;
&lt;li&gt;  TypeScript everywhere in a pnpm monorepo&lt;/li&gt;
&lt;li&gt;  PostgreSQL + Prisma for data, with a separate workers process for background jobs&lt;/li&gt;
&lt;li&gt;  BullMQ + Redis for the job queue&lt;/li&gt;
&lt;li&gt;  better-auth for authentication (sessions, OAuth)&lt;/li&gt;
&lt;li&gt;  Freemius as the merchant of record (not Stripe, more on that below)&lt;/li&gt;
&lt;li&gt;  Resend + React Email for transactional and lifecycle emails&lt;/li&gt;
&lt;li&gt;  Sentry + PostHog for error tracking and analytics&lt;/li&gt;
&lt;li&gt;  Docker Compose for both local dev and production on a single VPS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The monorepo has two apps and several packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
  web/       # Next.js: dashboard + marketing
  workers/   # Background job workers
packages/
  checker/   # Core check evaluation logic
  queue/     # BullMQ queue definitions
  db/        # Prisma schema + repositories
  types/     # Shared TypeScript types
  billing/   # Freemius integration
  ai/        # AI provider abstraction (noop by default)
  logger/    # Structured logging (Pino)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three architectural decisions worth talking about
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Two consecutive failures before marking a monitor down
&lt;/h3&gt;

&lt;p&gt;False positives are the biggest trust killer in uptime monitoring. A single timeout from a flaky CDN node will fire a 2am alert and destroy your credibility with the user.&lt;/p&gt;

&lt;p&gt;The evaluation logic in &lt;code&gt;packages/checker/&lt;/code&gt; requires two consecutive failures before transitioning a monitor to down. On the first failure it records the check result but holds the incident open. If the next check passes, it closes clean. If it fails again, it fires.&lt;/p&gt;

&lt;p&gt;This cut false positive alerts to near zero in testing against real Shopify stores.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Status pages read from Redis, not Postgres
&lt;/h3&gt;

&lt;p&gt;Status pages are ISR with a 30-second revalidate. When a store goes down, the merchant's customers hit the status page simultaneously. That is easily 100 to 300 requests per minute on a small VPS.&lt;/p&gt;

&lt;p&gt;If that read hits Postgres directly, the database becomes the bottleneck at exactly the worst moment. Instead, the workers write incident state to Redis on every check, and the status page reads from there. Postgres is never in the critical path during an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Content monitoring with Cheerio, no Playwright
&lt;/h3&gt;

&lt;p&gt;The competitor tracking feature diffs HTML content over time to detect price changes and promotional banners. The obvious tool is a headless browser, which handles JavaScript-rendered content perfectly.&lt;/p&gt;

&lt;p&gt;I do not run Playwright in the worker fleet. The entire production setup runs on a single VPS. A Playwright worker pool would consume 2 to 4 GB of RAM and make the whole deployment fragile.&lt;/p&gt;

&lt;p&gt;Cheerio handles the vast majority of eCommerce content just fine, JavaScript-rendered content is explicitly out of scope, and RAM stays predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The billing choice: Freemius over Stripe
&lt;/h2&gt;

&lt;p&gt;Freemius is a merchant of record for software. They handle VAT, sales tax, and payment processing. For a bootstrapped solo project, not dealing with tax compliance in 50 US states and the EU is worth the higher fee.&lt;/p&gt;

&lt;p&gt;The tradeoff: Freemius has a clunkier API and their SDK is less polished than Stripe's. I wrapped it in a &lt;code&gt;packages/billing&lt;/code&gt; package so the rest of the codebase does not care which billing provider is underneath.&lt;/p&gt;

&lt;p&gt;One plan ID per tier, not two (monthly/annual). Freemius handles the billing cycle internally as an integer (&lt;code&gt;1 = monthly&lt;/code&gt;, &lt;code&gt;12 = annual&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The workers
&lt;/h2&gt;

&lt;p&gt;The workers app runs a set of specialized BullMQ workers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;check-worker&lt;/code&gt; runs HTTP checks on a schedule&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;content-worker&lt;/code&gt; diffs HTML snapshots for content monitoring&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;competitor-page-worker&lt;/code&gt; fetches competitor pages&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;alert-worker&lt;/code&gt; sends notifications through configured channels (email, Slack, Discord, SMS, webhook)&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;digest-worker&lt;/code&gt; generates intelligence digests&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;scheduler&lt;/code&gt; enqueues jobs at the correct intervals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scheduler is pinned to one replica. BullMQ uses Redis for job storage with &lt;code&gt;noeviction&lt;/code&gt; policy and AOF durability. Job data must not be evicted.&lt;/p&gt;

&lt;p&gt;Alert sends are idempotent: before sending any notification, the worker checks for an existing send for the same &lt;code&gt;(incident_id, channel_id)&lt;/code&gt; within the last 5 minutes.&lt;/p&gt;

&lt;p&gt;This handles the case where a worker crashes mid-send and retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tenant isolation
&lt;/h2&gt;

&lt;p&gt;Every web-facing database function takes &lt;code&gt;teamId&lt;/code&gt; as its first argument.&lt;/p&gt;

&lt;p&gt;No exceptions.&lt;/p&gt;

&lt;p&gt;This is enforced by convention and code review, not by row-level security at the database layer. Worker repositories are exempt because job payloads are already tenant-scoped at enqueue time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;Beaconmon is live.&lt;/p&gt;

&lt;p&gt;The onboarding wizard detects Shopify and WooCommerce stores at step one and bulk-creates the competitor monitors at the end of the flow rather than incrementally.&lt;/p&gt;

&lt;p&gt;If you are curious about the codebase or have questions about any of these decisions, drop them in the comments.&lt;/p&gt;

&lt;p&gt;Built with Next.js, BullMQ, Prisma, and too much Redis.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>nextjs</category>
      <category>saas</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
