<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged puppeteer</title>
<link>https://coderlegion.com/tag/puppeteer</link>
<description>Powered by Question2Answer</description>
<item>
<title>Making a screenshot PDF searchable — no OCR, because we rendered the page</title>
<link>https://coderlegion.com/24991/making-a-screenshot-pdf-searchable-no-ocr-because-we-rendered-the-page</link>
<description>&lt;p&gt;If you generate PDFs from web pages by screenshotting them, you've probably hit this: the PDF looks perfect and is completely useless the moment you want to &lt;em&gt;use&lt;/em&gt; the text. &lt;code&gt;Ctrl+F&lt;/code&gt; finds nothing. You can't copy a sentence. A screen reader opens the document and sees one big image on an otherwise empty page.&lt;/p&gt;
&lt;p&gt;We archive whole pages this way — a full-height screenshot dropped onto a PDF page — so we had exactly this problem. Here's how we gave those PDFs a real, invisible, &lt;strong&gt;searchable&lt;/strong&gt; text layer with &lt;a rel=&quot;nofollow&quot; href=&quot;https://pdf-lib.js.org/&quot;&gt;&lt;code&gt;pdf-lib&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;@pdf-lib/fontkit&lt;/code&gt;, and the one part that turned out to be genuinely hard.&lt;/p&gt;
&lt;p&gt;The trick is the same one a &quot;searchable scan&quot; uses: draw the real text &lt;strong&gt;invisibly&lt;/strong&gt; over the image, at the exact coordinates where each word appears. The difference is that a scanner needs OCR to guess the text — we &lt;em&gt;rendered the page ourselves&lt;/em&gt;, so we already have the ground truth. No OCR, no guessing.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-of-it&quot;&gt;The shape of it&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;While the page is still open in the headless browser, ask the DOM where every word is.&lt;/li&gt;
&lt;li&gt;Assemble the PDF: embed the screenshot as the page background.&lt;/li&gt;
&lt;li&gt;For each word, &lt;code&gt;drawText&lt;/code&gt; it at its coordinates with &lt;code&gt;opacity: 0&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps 1 and 3 are easy. The trap is in &lt;em&gt;which&lt;/em&gt; words you're allowed to draw.&lt;/p&gt;
&lt;h3 id=&quot;step-1-ask-the-browser-where-the-words-are&quot;&gt;Step 1 — ask the browser where the words are&lt;/h3&gt;
&lt;p&gt;Running inside the page (Puppeteer's &lt;code&gt;page.evaluate&lt;/code&gt;), walk every text node and measure each word with a &lt;code&gt;Range&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
// ...for each word in each text node:
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const rects = range.getClientRects();
if (!rects.length) continue;           // display:none or empty line box
const b = rects[0];                    // first rect = where the word starts
out.push({
  t: word,
  x: b.left + window.scrollX,          // document coordinates, not viewport
  y: b.top  + window.scrollY,
  w: b.width, h: b.height,
  fs: parseFloat(getComputedStyle(el).fontSize) || 12,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;getClientRects()&lt;/code&gt; returns viewport coordinates, so we add &lt;code&gt;scrollX/scrollY&lt;/code&gt; to get &lt;strong&gt;document&lt;/strong&gt; coordinates — the ones that line up with a full-page screenshot. A word wrapped across two lines returns several rects; the first is where a search should land, so that's the one we keep.&lt;/p&gt;
&lt;h3 id=&quot;step-2-the-hidden-text-trap-the-hard-part-&quot;&gt;Step 2 — the hidden-text trap (the hard part)&lt;/h3&gt;
&lt;p&gt;The naive version above shipped a bug immediately. On a real page, search started highlighting words that &lt;strong&gt;weren't in the picture&lt;/strong&gt; — items inside a collapsed dropdown, off-canvas nav, &lt;code&gt;text-indent: -9999px&lt;/code&gt; screen-reader text. The image showed them collapsed; the text layer had them anyway.&lt;/p&gt;
&lt;p&gt;The tempting assumption is &quot;hidden text has no client rects.&quot; It's false. Only &lt;code&gt;display: none&lt;/code&gt; produces no rects. Every &lt;em&gt;other&lt;/em&gt; way of hiding something still reports perfectly good coordinates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;visibility: hidden&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;opacity: 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max-height: 0&lt;/code&gt; + &lt;code&gt;overflow: hidden&lt;/code&gt; (the collapsed accordion / submenu)&lt;/li&gt;
&lt;li&gt;parked offscreen with &lt;code&gt;text-indent: -9999px&lt;/code&gt; or a negative position&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So each word has to be checked against its &lt;strong&gt;whole ancestor chain&lt;/strong&gt;, not just its own box. Memoise one walk up the tree per element (a deep DOM would otherwise re-read computed styles thousands of times):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function chainInfo(el) {
  if (cache.has(el)) return cache.get(el);
  const above = el.parentElement ? chainInfo(el.parentElement)
                                 : { visible: true, opacity: 1, clips: [] };
  let info;
  if (!above.visible) {
    info = { visible: false, opacity: 0, clips: above.clips };
  } else {
    const cs      = getComputedStyle(el);
    const opacity = above.opacity * (parseFloat(cs.opacity) || 0);   // multiplied down the chain
    const visible = cs.visibility === 'visible' &amp;amp;&amp;amp; opacity &amp;gt; 0.05;
    const clips   = /\b(hidden|clip)\b/.test(cs.overflow)
      ? above.clips.concat([el.getBoundingClientRect()])
      : above.clips;
    info = { visible, opacity, clips };
  }
  cache.set(el, info);
  return info;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A word survives only if its chain is visible, its multiplied opacity is above a floor, it &lt;strong&gt;intersects&lt;/strong&gt; every clipping ancestor (a zero-height collapsed container clips its contents to nothing), and it isn't parked entirely outside the document box. Clipping is judged on &lt;em&gt;zero&lt;/em&gt; intersection, not partial — a word half-cut by a container is still half in the picture, and dropping it would lose real text.&lt;/p&gt;
&lt;p&gt;That single ancestor-walk is the difference between &quot;searchable PDF&quot; and &quot;PDF that lies about what's in it.&quot;&lt;/p&gt;
&lt;h3 id=&quot;step-3-flip-the-y-axis&quot;&gt;Step 3 — flip the Y axis&lt;/h3&gt;
&lt;p&gt;The DOM measures from the top-left; PDF space is bottom-left. So when we draw, Y inverts, plus a small nudge so the selection highlight sits on the glyphs rather than below them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const y = imageH - b.y - b.h + b.h * 0.2;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-4-the-font-problem&quot;&gt;Step 4 — the font problem&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;pdf-lib&lt;/code&gt;'s built-in fonts are &lt;strong&gt;WinAnsi&lt;/strong&gt;. They can't encode Cyrillic, Greek or CJK — and they even choke on curly typographic quotes, of which there are four on our own English blog. Draw an unencodable glyph and the call throws.&lt;/p&gt;
&lt;p&gt;So embed a real TrueType font, &lt;strong&gt;subset to only the glyphs actually used&lt;/strong&gt;, via fontkit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fontkit = require('@pdf-lib/fontkit');
doc.registerFontkit(fontkit);
const font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;subset: true&lt;/code&gt; is what keeps the cost near zero — only the handful of glyphs on the page get embedded, not the whole 300 KB face. We probe a small candidate list and take the first that exists on the box (DejaVu Sans on our Linux servers covers Latin + Cyrillic + Greek; Arial/Segoe locally). If none is found, we ship the image &lt;strong&gt;without&lt;/strong&gt; a text layer rather than failing the capture — losing search is not losing the archive.&lt;/p&gt;
&lt;h3 id=&quot;step-5-draw-it-invisibly-and-don-t-let-one-glyph-kill-the-archive&quot;&gt;Step 5 — draw it invisibly, and don't let one glyph kill the archive&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;for (const b of boxes) {
  const y = imageH - b.y - b.h + b.h * 0.2;
  try {
    pdfPage.drawText(b.t, { x: b.x, y, size: b.fs, font, opacity: 0 });
    drawn++;
  } catch (e) {
    skipped++;   // a glyph even this font can't encode — skip the word, keep the PDF
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;opacity: 0&lt;/code&gt; is the whole point: the image is what you see, the text is what you search. And the per-word &lt;code&gt;try/catch&lt;/code&gt; matters — a single exotic glyph should cost you one unsearchable word, not the entire multi-page document.&lt;/p&gt;
&lt;h3 id=&quot;did-it-actually-work-&quot;&gt;Did it actually work?&lt;/h3&gt;
&lt;p&gt;Measured on a Cyrillic page, before/after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;626 of 626&lt;/strong&gt; visible words got coordinates and a text entry&lt;/li&gt;
&lt;li&gt;file grew &lt;strong&gt;7086 KB → 7147 KB — +0.9%&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/ToUnicode&lt;/code&gt; is present in the output, so the text is genuinely searchable &lt;em&gt;and&lt;/em&gt; copyable, not just present&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That +0.9% buys you &lt;code&gt;Ctrl+F&lt;/code&gt;, copy-paste, and a document a screen reader can actually read — on what is still, pixel for pixel, an exact image of the page.&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24991/making-a-screenshot-pdf-searchable-no-ocr-because-we-rendered-the-page</guid>
<pubDate>Thu, 20 Aug 2026 13:13:18 +0000</pubDate>
</item>
<item>
<title>How We Built a Free SEO Audit Tool with Puppeteer and Chromium</title>
<link>https://coderlegion.com/18719/how-we-built-a-free-seo-audit-tool-with-puppeteer-and-chromium</link>
<description>&lt;p&gt;&lt;em&gt;Real browser rendering, 60+ modular checks, and the engineering lessons we learned building a headless Chromium audit pipeline&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;When we started building our SEO auditing pipeline, the first architectural decision we made was also the most important one:&lt;/p&gt;
&lt;p&gt;We would never parse raw HTML.&lt;/p&gt;
&lt;p&gt;Most SEO audit tools still rely on that approach because it’s lightweight and efficient for static websites. But frontend frameworks like React, Next.js, and Vue changed the landscape completely.&lt;/p&gt;
&lt;p&gt;We kept running into the same issue:&lt;/p&gt;
&lt;p&gt;Traditional parsers were auditing code that users and Google never actually saw.&lt;/p&gt;
&lt;p&gt;So we took a different route:&lt;br&gt;
real browser rendering with Puppeteer and headless Chromium.&lt;/p&gt;
&lt;p&gt;Here’s how we built the system and what we learned along the way.&lt;/p&gt;
&lt;h3 id=&quot;the-core-problem-with-html-parsers&quot;&gt;The Core Problem With HTML Parsers&lt;/h3&gt;
&lt;p&gt;Most SEO auditors work something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const response = await fetch(url);
const html = await response.text();

// parse html and inspect tags
For static sites, that works fine.
But JavaScript applications often return almost empty HTML responses initially:
&amp;lt;html&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;div id=&quot;root&quot;&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;script src=&quot;/static/js/main.chunk.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The actual SEO relevant content, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;H1 tags&lt;/li&gt;
&lt;li&gt;Meta descriptions&lt;/li&gt;
&lt;li&gt;Structured data&lt;/li&gt;
&lt;li&gt;Canonical tags&lt;/li&gt;
&lt;li&gt;Images&lt;/li&gt;
&lt;li&gt;Internal links&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;doesn’t exist yet.&lt;/p&gt;
&lt;p&gt;It gets generated after JavaScript executes in the browser.&lt;/p&gt;
&lt;p&gt;An HTML parser never sees that content.&lt;/p&gt;
&lt;p&gt;Googlebot renders JavaScript. Your audit tool should, too.&lt;/p&gt;
&lt;p&gt;One of the most surprising things we discovered during testing was how little meaningful content some frameworks returned before hydration. In several React-based applications, the initial response contained almost none of the content users eventually saw in the browser.&lt;/p&gt;
&lt;p&gt;In our internal testing, more than half of the React-based sites we audited returned incomplete metadata before rendering. Some pages were missing titles, canonical tags, structured data, or even visible heading content entirely until JavaScript finished executing.&lt;/p&gt;
&lt;p&gt;That gap became impossible to ignore.&lt;/p&gt;
&lt;h3 id=&quot;why-we-chose-puppeteer&quot;&gt;Why We Chose Puppeteer&lt;/h3&gt;
&lt;p&gt;We evaluated several options before deciding on our stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Playwright: excellent tooling, but heavier than we needed for a Chromium-only workflow&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Selenium: powerful, but designed more for browser testing than rendering audits&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cheerio + axios: extremely fast, but limited to static HTML parsing&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Puppeteer: lightweight Chromium automation with a straightforward API and strong ecosystem support&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Puppeteer ultimately made the most sense for our use case.&lt;/p&gt;
&lt;p&gt;We didn’t need multi-browser automation.&lt;/p&gt;
&lt;p&gt;We needed rendering accuracy.&lt;/p&gt;
&lt;p&gt;That narrowed the field quickly.&lt;/p&gt;
&lt;h3 id=&quot;the-rendering-pipeline&quot;&gt;The Rendering Pipeline&lt;/h3&gt;
&lt;p&gt;Here’s a simplified version of the core audit flow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const puppeteer = require('puppeteer');

async function auditPage(url) {
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  const page = await browser.newPage();

  await page.setUserAgent(
    'Mozilla/5.0 (compatible; DeepAuditBot/1.0; +https://axiondeepdigital.com)'
  );

  const resources = [];
  page.on('request', (req) =&amp;gt; resources.push(req));

  await page.goto(url, {
    waitUntil: 'networkidle2',
    timeout: 30000,
  });

  await autoScroll(page);

  const dom = await page.evaluate(() =&amp;gt; document.documentElement.outerHTML);

  await browser.close();

  return { dom, resources };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key detail here is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;waitUntil: 'networkidle2'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells Puppeteer to wait until there are no more than two active network requests for at least 500ms.&lt;/p&gt;
&lt;p&gt;Without this step, audits frequently captured incomplete pages before JavaScript finished rendering critical content.&lt;/p&gt;
&lt;p&gt;This became especially important for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Hydration heavy React apps&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Lazy-loaded images&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dynamically injected metadata&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client-side routing frameworks&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Waiting for the network to stabilize before auditing eliminated many of the incomplete renders we encountered early in development.&lt;/p&gt;
&lt;h3 id=&quot;handling-lazy-loaded-content&quot;&gt;Handling Lazy-Loaded Content&lt;/h3&gt;
&lt;p&gt;Another challenge we encountered was lazy loading.&lt;/p&gt;
&lt;p&gt;Many sites only load images and components once the user scrolls down the page. A simple page load misses large portions of the content entirely.&lt;/p&gt;
&lt;p&gt;To solve this, we implemented an incremental scrolling helper:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function autoScroll(page) {
  await page.evaluate(async () =&amp;gt; {
    await new Promise((resolve) =&amp;gt; {
      let totalHeight = 0;
      const distance = 200;

      const timer = setInterval(() =&amp;gt; {
        window.scrollBy(0, distance);
        totalHeight += distance;

        if (totalHeight &amp;gt;= document.body.scrollHeight) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    });
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This scrolling behavior triggers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Intersection observers&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Lazy load listeners&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deferred image requests&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;in much the same way as real user interaction would.&lt;/p&gt;
&lt;p&gt;Without scrolling, some audits completely missed below-the-fold image sections, deferred components, and dynamically injected content.&lt;/p&gt;
&lt;h3 id=&quot;building-the-audit-engine&quot;&gt;Building the Audit Engine&lt;/h3&gt;
&lt;p&gt;Once we had a fully rendered page, we built the audit engine itself as a collection of independent modules.&lt;/p&gt;
&lt;p&gt;The structure looked roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;checks/
  meta/
    title.js
    description.js
    og-tags.js
    canonical.js
  headings/
    h1-presence.js
    heading-hierarchy.js
  images/
    alt-text.js
    lazy-load-detection.js
    oversized-images.js
  performance/
    render-blocking.js
    resource-hints.js
    font-loading.js
  structured-data/
    json-ld-validation.js
    schema-types.js
  links/
    internal-links.js
    broken-links.js
    anchor-text.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each check receives:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The rendered DOM&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Network resource data&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Page metrics&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and returns a standardized result object:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  check: 'h1-presence',
  status: 'pass',
  message: 'H1 tag found: &quot;Your Page Title&quot;',
  impact: 'high'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some checks were intentionally simple:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (!document.querySelector('h1')) {
  return fail('Missing H1 tag');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Others required additional context, especially performance analysis and structured data validation.&lt;/p&gt;
&lt;p&gt;This modular structure ended up saving us repeatedly as the platform expanded. Once the number of checks started growing, isolating each audit into independent modules made debugging, maintenance, and feature development far easier.&lt;/p&gt;
&lt;p&gt;It also allowed us to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Disable problematic checks quickly&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add new audit rules independently&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Prioritize issues by impact&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Generate cleaner reporting output&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As the project evolved, modularity became one of the best architectural decisions we made.&lt;/p&gt;
&lt;h3 id=&quot;challenges-we-didn-t-anticipate&quot;&gt;Challenges We Didn’t Anticipate&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Timeout Handling&lt;/strong&gt;&lt;br&gt;
Some pages are genuinely slow.&lt;/p&gt;
&lt;p&gt;Large JavaScript bundles, third-party scripts, tracking pixels, and API delays can dramatically increase render time.&lt;/p&gt;
&lt;p&gt;Originally, slow pages caused full audit failures.&lt;/p&gt;
&lt;p&gt;We eventually redesigned the pipeline so incomplete audits could still return partial results instead of failing entirely.&lt;/p&gt;
&lt;p&gt;That change made the platform far more resilient in production environments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Bot Detection&lt;/strong&gt;&lt;br&gt;
Some sites actively detect headless browsers and serve different content.&lt;/p&gt;
&lt;p&gt;In a few cases, pages rendered perfectly in a normal browser but returned stripped-down responses when rendered in headless Chromium.&lt;/p&gt;
&lt;p&gt;We mitigated part of the problem using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Realistic user agents&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Browser fingerprint adjustments&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Standard viewport sizes&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;but avoiding bot detection remains an ongoing challenge across the industry.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Single Page App Routing&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Single-page applications introduced another issue:&lt;br&gt;
Deep routes sometimes triggered unexpected re rendering behavior during navigation.&lt;/p&gt;
&lt;p&gt;We initially experimented with broader crawling behavior, but dynamic client-side routing made the process unreliable very quickly.&lt;/p&gt;
&lt;p&gt;In several cases, navigating between routes caused the application state to reset entirely, producing inconsistent audit results between runs.&lt;/p&gt;
&lt;p&gt;We eventually simplified the pipeline and audited only the exact URL requested.&lt;/p&gt;
&lt;p&gt;That decision made results far more predictable and reduced unnecessary complexity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Memory Management&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Chromium gets expensive fast under concurrency.&lt;/p&gt;
&lt;p&gt;Early versions of the system launched a fresh browser instance for every audit request. During our first large-scale load tests, memory usage escalated far faster than we expected.&lt;/p&gt;
&lt;p&gt;The rendering itself worked well.&lt;/p&gt;
&lt;p&gt;The infrastructure did not.&lt;/p&gt;
&lt;p&gt;Under concurrency, even small memory leaks became amplified rapidly because every Chromium instance carried the overhead of a full browser environment.&lt;/p&gt;
&lt;p&gt;At one point, a single improperly terminated Chromium process accumulated enough memory to destabilize an entire worker node under concurrent load.&lt;/p&gt;
&lt;p&gt;That was the moment we realized browser lifecycle management mattered just as much as the audit logic itself.&lt;/p&gt;
&lt;p&gt;We eventually learned that even one failed browser cleanup could quietly accumulate memory until the worker became unstable under load.&lt;/p&gt;
&lt;h3 id=&quot;what-we-d-do-differently&quot;&gt;What We’d Do Differently&lt;/h3&gt;
&lt;p&gt;If we were starting over, we would implement a reusable browser pool from day one.&lt;/p&gt;
&lt;p&gt;Launching a fresh Chromium instance for every audit works initially, but it becomes inefficient very quickly at scale.&lt;/p&gt;
&lt;p&gt;Reusing browser instances with isolated pages is far more resource-efficient and improves throughput under concurrency.&lt;/p&gt;
&lt;p&gt;In later testing, browser reuse reduced memory overhead noticeably compared to isolated browser launches per request.&lt;/p&gt;
&lt;p&gt;We also would have invested earlier in DOM snapshot caching.&lt;/p&gt;
&lt;p&gt;Rendering is by far the most expensive part of the pipeline, especially for repeat audits against the same URL.&lt;/p&gt;
&lt;p&gt;Caching rendered snapshots would have reduced both rendering overhead and infrastructure costs.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Building a browser-rendered SEO auditing system proved far more demanding than parsing static HTML, but it also exposed how incomplete traditional auditing approaches had become for JavaScript-driven applications.&lt;/p&gt;
&lt;p&gt;As we expanded the platform, we found ourselves solving problems that had less to do with SEO itself and more to do with rendering stability, browser orchestration, memory management, and infrastructure scaling.&lt;/p&gt;
&lt;p&gt;What started as a rendering experiment eventually forced us to rethink nearly every assumption traditional SEO tools make about how websites should be analyzed in modern frontend environments.&lt;/p&gt;
&lt;p&gt;The result became the foundation for our public SEO auditing platform, allowing developers and site owners to analyze pages using the same Chromium-based rendering pipeline we built internally.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;http://axiondeepdigital.com/free-seo-audit&quot;&gt;axiondeepdigital.com/free-seo-audit&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Crystal A. Gutierrez&lt;/strong&gt; is Chairperson &amp;amp; Infrastructure Lead at Axion Deep Digital, a web development and SEO agency based in Las Cruces, NM.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18719/how-we-built-a-free-seo-audit-tool-with-puppeteer-and-chromium</guid>
<pubDate>Sat, 23 May 2026 01:30:15 +0000</pubDate>
</item>
</channel>
</rss>