<?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: Branch42 Labs</title>
    <description>The latest articles on DEV Community by Branch42 Labs (branch42-labs).</description>
    <link>https://dev.to/branch42-labs</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%2Forganization%2Fprofile_image%2F15465%2F094629f2-849b-4f73-984b-00157293442b.png</url>
      <title>DEV Community: Branch42 Labs</title>
      <link>https://dev.to/branch42-labs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/branch42-labs"/>
    <language>en</language>
    <item>
      <title>Your crawler sees an empty div. Crawling a JavaScript SPA with Playwright</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:01:12 +0000</pubDate>
      <link>https://dev.to/branch42-labs/your-crawler-sees-an-empty-div-crawling-a-javascript-spa-with-playwright-2aed</link>
      <guid>https://dev.to/branch42-labs/your-crawler-sees-an-empty-div-crawling-a-javascript-spa-with-playwright-2aed</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; On a client-rendered SPA, the HTML the server sends is a script tag and an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt;. An HTTP crawler parses that, finds no links and stops. Load each page in a headless browser (Playwright or Puppeteer), wait for it to render, read &lt;code&gt;a[href]&lt;/code&gt; from the live DOM and queue what you find. If you want the data rather than the pages, the JSON API the app calls is usually the better thing to crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  The empty shell
&lt;/h2&gt;

&lt;p&gt;I pointed a plain crawler at a React dashboard and got exactly one page back. Here's why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;$ curl -s https://app.example.com/
&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/index-4f2a.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every link on that site exists only after the bundle runs, hits the API and renders. &lt;code&gt;wget --mirror&lt;/code&gt;, Scrapy or &lt;code&gt;fetch&lt;/code&gt; plus cheerio all see the shell above. You need something that executes JavaScript, which means a browser engine you can drive from code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crawler
&lt;/h2&gt;

&lt;p&gt;Setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i playwright robots-parser
npx playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;crawl.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&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="s2"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;robotsParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;robots-parser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;MAX_PAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HASH_ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;// true for apps with URLs like /#/settings&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SKIP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/logout|sign-&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;out|delete|remove/i&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;robotsUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/robots.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;robotsTxt&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;robotsUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;r&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="dl"&gt;""&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="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="nx"&gt;robots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;robotsParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;robotsUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;robotsTxt&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;DELAY_MS&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCrawlDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HASH_ROUTES&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#/&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&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;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&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;apiCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request&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;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&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="s2"&gt;xhr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceType&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="nx"&gt;apiCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&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;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pages&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;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_PAGES&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&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;robots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isAllowed&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="c1"&gt;// Wait for the app's own API calls to settle, but don't hang on polling.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForLoadState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&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;hrefs&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="nf"&gt;$eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a[href]&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;links&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;links&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;a&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="na"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;links&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hrefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;for &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;href&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;hrefs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;SKIP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;apiCalls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;apiCalls&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node crawl.mjs https://your-site/&lt;/code&gt;. I tested it against a small pushState SPA whose links only appear 300 ms after load. It found every route, skipped the &lt;code&gt;Disallow&lt;/code&gt;ed one and stayed away from &lt;code&gt;/logout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What each part is doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BFS with a &lt;code&gt;seen&lt;/code&gt; set&lt;/strong&gt;, capped at &lt;code&gt;MAX_PAGES&lt;/code&gt;, so it can't run away on a big site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;networkidle&lt;/code&gt; with a timeout.&lt;/strong&gt; It resolves after 500 ms with no network connections. The &lt;a href="https://playwright.dev/docs/api/class-page#page-goto" rel="noopener noreferrer"&gt;Playwright docs&lt;/a&gt; discourage it for tests, and they're right, but for a crawler it's a decent "the app has fetched and rendered" signal. The timeout covers apps that poll forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;a.href&lt;/code&gt; instead of &lt;code&gt;getAttribute("href")&lt;/code&gt;&lt;/strong&gt;, because the browser hands you the resolved absolute URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;robots.txt and &lt;code&gt;Crawl-delay&lt;/code&gt;&lt;/strong&gt; via &lt;code&gt;robots-parser&lt;/code&gt;, plus a one-second floor between pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;apiCalls&lt;/code&gt; set.&lt;/strong&gt; This one turns out to be the most useful output (more on that below).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Using Puppeteer? Same loop. Use &lt;code&gt;waitUntil: "networkidle2"&lt;/code&gt; on &lt;code&gt;goto&lt;/code&gt; and the same &lt;code&gt;$$eval&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that will bite you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash routing.&lt;/strong&gt; With &lt;code&gt;/#/settings&lt;/code&gt;-style URLs the fragment never reaches the server and most crawlers strip it, so every route looks like one page. Flip &lt;code&gt;HASH_ROUTES&lt;/code&gt; to keep &lt;code&gt;#/&lt;/code&gt; fragments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buttons pretending to be links.&lt;/strong&gt; &lt;code&gt;onClick={() =&amp;gt; router.push("/reports")}&lt;/code&gt; on a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; has no &lt;code&gt;href&lt;/code&gt;, so a link crawler never finds it. Click candidates and check &lt;code&gt;page.url()&lt;/code&gt; afterwards, or read &lt;code&gt;/sitemap.xml&lt;/code&gt;, or find the route config in the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infinite scroll.&lt;/strong&gt; Loop &lt;code&gt;await page.mouse.wheel(0, 5000)&lt;/code&gt; plus a short wait until the page height stops changing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Login.&lt;/strong&gt; Sign in once, &lt;code&gt;await context.storageState({ path: "auth.json" })&lt;/code&gt;, then &lt;code&gt;browser.newContext({ storageState: "auth.json" })&lt;/code&gt; for every crawl. And keep the logout skip, because one stray logout click and you're crawling the login page 50 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query string explosions.&lt;/strong&gt; Sort and filter params can multiply one list into thousands of URLs. Strip what you don't need in &lt;code&gt;key()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does Google have the same problem?
&lt;/h2&gt;

&lt;p&gt;Less than it used to. Per Google's &lt;a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics" rel="noopener noreferrer"&gt;JavaScript SEO basics&lt;/a&gt;, Googlebot crawls the HTML, queues the page for rendering in an evergreen headless Chromium, and then extracts links from the rendered HTML. Rendering can lag behind crawling, it only follows &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; elements with an &lt;code&gt;href&lt;/code&gt;, and Google tells you not to use &lt;code&gt;#&lt;/code&gt; fragments to load different content. Other bots may not run JavaScript at all, which is why SSR or pre-rendering is still the advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Often you want the API, not the HTML
&lt;/h2&gt;

&lt;p&gt;An SPA builds its pages from JSON. If you're after the data, &lt;code&gt;GET /api/projects?page=2&lt;/code&gt; is more stable than whatever CSS classes this quarter's redesign uses. Watch the Network panel with the Fetch/XHR filter on while you use the app, or just read the &lt;code&gt;apiCalls&lt;/code&gt; the crawler logged.&lt;/p&gt;

&lt;p&gt;That's also why I ended up using &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt; (I work on it) for my own apps. Its crawler runs in my real Chrome tab, so the SPA renders with my logged-in session and there's no headless browser to log in. It reads the rendered &lt;code&gt;a[href]&lt;/code&gt; links from the start page and visits them one level deep on the same origin. It skips logout and delete links and records the API calls per page into an endpoint map. It doesn't click buttons, scroll or follow hash routes, so the Playwright script above still has a place.&lt;/p&gt;

&lt;p&gt;Only crawl sites you own or are allowed to test.&lt;/p&gt;

&lt;p&gt;The full guide, with an FAQ, is at &lt;a href="https://netraptor.dev/guides/crawl-javascript-website" rel="noopener noreferrer"&gt;netraptor.dev/guides/crawl-javascript-website&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
      <category>playwright</category>
    </item>
    <item>
      <title>Scraping a site behind a login in Python (and why I go for the API instead)</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:23:47 +0000</pubDate>
      <link>https://dev.to/branch42-labs/scraping-a-site-behind-a-login-in-python-and-why-i-go-for-the-api-instead-2n57</link>
      <guid>https://dev.to/branch42-labs/scraping-a-site-behind-a-login-in-python-and-why-i-go-for-the-api-instead-2n57</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Log in once in code and reuse the session. For a plain form login, that's a &lt;code&gt;requests.Session&lt;/code&gt; that posts the form with its CSRF token. For a JavaScript login or 2FA, use Playwright, log in (by hand if needed) and save &lt;code&gt;storage_state&lt;/code&gt; to a file. Then, before you write a single CSS selector, open DevTools and look for the JSON API the logged-in page calls. Requesting that with the same session is usually less code and breaks less often.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the boring but important bit
&lt;/h2&gt;

&lt;p&gt;I only do this on my own apps, for my own account's data, or on sites whose terms allow automated access. Read &lt;code&gt;robots.txt&lt;/code&gt; and the terms, go slowly, and check for an official API or export first. Don't share credentials, and don't try to get around CAPTCHAs or paywalls. A CAPTCHA on the login page means the site doesn't want this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: watch the login in DevTools
&lt;/h2&gt;

&lt;p&gt;Before writing code, I log in by hand with the Network panel open:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;F12, Network tab, tick &lt;strong&gt;Preserve log&lt;/strong&gt; (the login redirect would wipe the list otherwise).&lt;/li&gt;
&lt;li&gt;Log in.&lt;/li&gt;
&lt;li&gt;Click the login request. The &lt;strong&gt;Payload&lt;/strong&gt; tab shows the field names the form really sends, including the CSRF token. The &lt;strong&gt;Headers&lt;/strong&gt; tab shows the &lt;code&gt;Set-Cookie&lt;/code&gt; that comes back.&lt;/li&gt;
&lt;li&gt;Click a request made after login. If it has an &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; header, the app uses a token and cookies alone won't get you in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ten minutes here saves an hour of guessing field names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: requests.Session + BeautifulSoup
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pip install requests beautifulsoup4&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The trick is to GET the login page with the same session first, then copy every named input from the form. That picks up the CSRF token (&lt;code&gt;csrf_token&lt;/code&gt;, &lt;code&gt;_token&lt;/code&gt;, &lt;code&gt;authenticity_token&lt;/code&gt;, &lt;code&gt;csrfmiddlewaretoken&lt;/code&gt;, whatever the framework calls it) without hard-coding the name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urljoin&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders-export/1.0 (you@example.com)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_EMAIL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;# match the Payload tab
&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_PASSWORD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;login&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;urljoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Login failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# lots of sites return 200 + the login page
&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/account/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table.orders tbody tr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;td&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;td&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;td&lt;/span&gt;&lt;span class="sh"&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 the token is in &lt;code&gt;&amp;lt;meta name="csrf-token"&amp;gt;&lt;/code&gt;, read it from there and send it as an &lt;code&gt;X-CSRF-Token&lt;/code&gt; header instead.&lt;/p&gt;

&lt;p&gt;To avoid logging in every run, dump &lt;code&gt;session.cookies.get_dict()&lt;/code&gt; to a JSON file and load it back with &lt;code&gt;session.cookies.update(...)&lt;/code&gt;. Log in again only when it stops working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Playwright + storage_state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pip install playwright &amp;amp;&amp;amp; playwright install chromium&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;When the login is JavaScript-driven, or the data only shows up after render, I use a real browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;STATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_EMAIL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_PASSWORD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sign in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;storage_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;storage_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/account/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table.orders tbody tr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all_inner_texts&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;storage_state&lt;/code&gt; saves cookies and localStorage. It doesn't save sessionStorage, which bites you if the app keeps its token there.&lt;/p&gt;

&lt;h3&gt;
  
  
  2FA and SSO
&lt;/h3&gt;

&lt;p&gt;Don't automate it. Log in by hand once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Log in in the browser window, then press Enter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;storage_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reuse &lt;code&gt;state.json&lt;/code&gt; until the session dies, then do it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3 (my default): call the JSON endpoint
&lt;/h2&gt;

&lt;p&gt;Most logged-in apps build their pages from JSON calls. Asking for that JSON gives you named fields instead of table cells, and a redesign doesn't break your script.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;DevTools → Network → &lt;strong&gt;Fetch/XHR&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Reload the page with the data.&lt;/li&gt;
&lt;li&gt;Check the &lt;strong&gt;Preview&lt;/strong&gt; tab of each request until you find the data. &lt;code&gt;Cmd/Ctrl+F&lt;/code&gt; in the Network panel searches response bodies, so search for a value you can see on screen.&lt;/li&gt;
&lt;li&gt;Note the URL, query params and any custom headers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then use the session you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Session expired, log in again&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From Playwright, &lt;code&gt;ctx.request.get(f"{BASE}/api/orders")&lt;/code&gt; sends the context's cookies for you. Or move the cookies over to &lt;code&gt;requests&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cookies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick way to prototype: right-click the request in DevTools → &lt;strong&gt;Copy as cURL&lt;/strong&gt;, run it in a terminal, then convert it to Python (by hand, or with curlconverter). That command includes your cookies, so keep it out of Slack and issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't commit your session
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;state.json&lt;/code&gt; and &lt;code&gt;cookies.json&lt;/code&gt; work like a password until they expire. Put them and &lt;code&gt;.env&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt; before the first commit, read credentials from env vars, and use a separate test account for anything on a cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's your own app
&lt;/h2&gt;

&lt;p&gt;Finding every endpoint by clicking through the Network tab gets old fast. For my own apps I use &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension I work on. It records the fetch/XHR calls the logged-in app makes and groups them into endpoints like &lt;code&gt;GET /api/orders/{id}&lt;/code&gt;, with schemas and real responses. It can also crawl the same-origin links on a start page in your logged-in tab to find more (one level deep, and it skips logout/delete-style links). It copies requests as cURL, PowerShell or fetch (no Python, so curlconverter still helps) and exports Postman or HAR.&lt;/p&gt;

&lt;p&gt;The full guide, with more on CSRF tokens, Selenium cookies and rate limits, is here: &lt;a href="https://netraptor.dev/guides/scrape-website-behind-login" rel="noopener noreferrer"&gt;How to scrape a website that requires a login&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I find all pages on a website (and the ones every tool misses)</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:21:13 +0000</pubDate>
      <link>https://dev.to/branch42-labs/how-i-find-all-pages-on-a-website-and-the-ones-every-tool-misses-k1f</link>
      <guid>https://dev.to/branch42-labs/how-i-find-all-pages-on-a-website-and-the-ones-every-tool-misses-k1f</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Check &lt;code&gt;/sitemap.xml&lt;/code&gt; (or the &lt;code&gt;Sitemap:&lt;/code&gt; line in &lt;code&gt;/robots.txt&lt;/code&gt;), run a &lt;code&gt;site:example.com&lt;/code&gt; search, then crawl the site. Add the Wayback Machine for old URLs and Search Console if it's yours. None of these see pages behind a login or routes that only exist in your app's JavaScript. For those, crawl from a browser tab where you're already signed in.&lt;/p&gt;

&lt;p&gt;I get asked "how many pages does this site actually have?" every time I inherit a project. The honest answer is that no single source knows. Here's the order I check things in, with the commands I use.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The sitemap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/robots.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^sitemap:'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/sitemap.xml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;loc&amp;gt;[^&amp;lt;]+'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;loc&amp;gt;//'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; entries point at more &lt;code&gt;.xml&lt;/code&gt; files, you're looking at a sitemap index. Run the second command on each child. One sitemap file maxes out at 50,000 URLs or 50 MB uncompressed (&lt;a href="https://www.sitemaps.org/protocol.html" rel="noopener noreferrer"&gt;sitemaps.org&lt;/a&gt;), so big sites always split.&lt;/p&gt;

&lt;p&gt;What it misses: anything the owner didn't list. Apps often only list their marketing pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Google &lt;code&gt;site:&lt;/code&gt; search
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;site:example.com&lt;/code&gt;, or &lt;code&gt;site:example.com/docs&lt;/code&gt; for one section. Good for a quick look, useless for a count. Google's own &lt;a href="https://developers.google.com/search/docs/monitor-debug/search-operators/all-search-site" rel="noopener noreferrer"&gt;docs&lt;/a&gt; say it "doesn't necessarily return all the URLs that are indexed."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Search Console (your own site only)
&lt;/h2&gt;

&lt;p&gt;Indexing → Pages. The &lt;a href="https://support.google.com/webmasters/answer/7440203" rel="noopener noreferrer"&gt;Page indexing report&lt;/a&gt; splits indexed from not-indexed URLs and gives the reason for each group. Click a reason, then Export. Example lists cap at 1,000 rows, but the not-indexed groups are where I usually find forgotten pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Crawl it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.screamingfrog.co.uk/seo-spider/" rel="noopener noreferrer"&gt;Screaming Frog&lt;/a&gt; is free up to 500 URLs per crawl. If you'd rather stay in the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget &lt;span class="nt"&gt;--spider&lt;/span&gt; &lt;span class="nt"&gt;--recursive&lt;/span&gt; &lt;span class="nt"&gt;--level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;inf &lt;span class="nt"&gt;--no-verbose&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;crawl.log https://example.com/

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'https?://[^ ]+'&lt;/span&gt; crawl.log | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; urls.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wget&lt;/code&gt; respects &lt;code&gt;robots.txt&lt;/code&gt; in recursive mode and doesn't run JavaScript. Keep that second part in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Wayback Machine for URLs that used to exist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://web.archive.org/cdx/search/cdx?url=example.com/*&amp;amp;fl=original&amp;amp;collapse=urlkey&amp;amp;filter=statuscode:200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/*&lt;/code&gt; is a prefix match, &lt;code&gt;fl=original&lt;/code&gt; returns just URLs, and &lt;code&gt;collapse=urlkey&lt;/code&gt; drops duplicate captures. Expect noise. Check what still loads. Docs: &lt;a href="https://github.com/internetarchive/wayback/blob/master/wayback-cdx-server/README.md" rel="noopener noreferrer"&gt;CDX server API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  About "hidden pages"
&lt;/h2&gt;

&lt;p&gt;Usually these are orphan pages: on the server, linked from nowhere. On your own site, diff your CMS or access logs against the crawl output. Wayback and &lt;code&gt;site:&lt;/code&gt; sometimes surface pages that were linked once. Wordlist-guessing URLs (directory brute forcing) is for your own site or a scoped, authorised test. On someone else's site it's a good way to get blocked or worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where all of this falls over: SPAs and logged-in apps
&lt;/h2&gt;

&lt;p&gt;My last two projects were a React dashboard and an admin app behind SSO. Every method above returned the login page and a handful of marketing URLs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a single-page app the server sends an empty shell. Routes live in JavaScript, and some are only reachable through &lt;code&gt;onClick&lt;/code&gt; handlers instead of &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Behind a login, a crawler without your session gets redirected on every URL. Pasting cookies into a crawler works until the session expires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a quick look at one rendered page, I paste this into the DevTools Console. It reads the live DOM, so JS-rendered links count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href]&lt;/span&gt;&lt;span class="dl"&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;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;For the whole app I use &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension I work on. Its crawler runs in my own logged-in tab: it reads the &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; links on the start page after render, keeps same-origin ones, skips anything that looks like logout or delete, and loads each in turn while recording the API calls every page makes. It goes one level from the start page (start from a section index or use a path prefix to go deeper), and it doesn't follow &lt;code&gt;#/hash&lt;/code&gt; routes. Free crawls 25 pages per run. I only point it at apps I build or am allowed to test.&lt;/p&gt;

&lt;p&gt;Full guide with the Search Console and crawler details: &lt;a href="https://netraptor.dev/guides/find-all-pages-on-a-website" rel="noopener noreferrer"&gt;How to find all pages on a website&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>productivity</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What a CORS error actually means, and how I fix each Chrome message</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:56:57 +0000</pubDate>
      <link>https://dev.to/branch42-labs/what-a-cors-error-actually-means-and-how-i-fix-each-chrome-message-4gk</link>
      <guid>https://dev.to/branch42-labs/what-a-cors-error-actually-means-and-how-i-fix-each-chrome-message-4gk</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A CORS error means the browser refused to give your page a response from another origin, because the server didn't send an &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header that allows you. The fix lives on the server, or in a dev proxy so the browser only talks to its own origin. The text after &lt;code&gt;has been blocked by CORS policy:&lt;/code&gt; in the Console tells you which header to fix. If the request sends JSON or an &lt;code&gt;Authorization&lt;/code&gt; header, look at the OPTIONS preflight first. The Network panel files it under &lt;strong&gt;Other&lt;/strong&gt;, so the Fetch/XHR filter hides it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second version of CORS
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An origin is scheme + host + port. &lt;code&gt;localhost:5173&lt;/code&gt; and &lt;code&gt;localhost:8080&lt;/code&gt; are different origins.&lt;/li&gt;
&lt;li&gt;The browser enforces CORS. Postman and curl don't, which is why "it works in Postman" tells you nothing.&lt;/li&gt;
&lt;li&gt;The server opts in by sending &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; and friends. Your frontend can't grant itself access.&lt;/li&gt;
&lt;li&gt;JSON bodies, &lt;code&gt;Authorization&lt;/code&gt; headers and methods other than GET/HEAD/POST trigger a &lt;strong&gt;preflight&lt;/strong&gt;: an OPTIONS request, sent without cookies or tokens, asking whether the real request is allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Read the message, not the TypeError
&lt;/h2&gt;

&lt;p&gt;In code you only get &lt;code&gt;TypeError: Failed to fetch&lt;/code&gt; (or Axios's &lt;code&gt;Network Error&lt;/code&gt;). The browser hides the reason from scripts on purpose. The Console has the real message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access to fetch at 'https://api.example.com/orders' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what I do for each ending Chrome prints. The strings come from Chromium's &lt;code&gt;cors_error_string.cc&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message ends with&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;No 'Access-Control-Allow-Origin' header is present on the requested resource.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send the header. If the response was a 401/404/500, your error path probably skips the CORS middleware.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;It does not have HTTP ok status.&lt;/code&gt; (preflight)&lt;/td&gt;
&lt;td&gt;The OPTIONS request got a non-2xx response. Usually auth middleware rejecting a preflight that has no token. Answer OPTIONS with 204 before auth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Redirect is not allowed for a preflight request.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The preflight hit a 301/302/307. Use the final URL (trailing slash, https, login redirect).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;...must not be the wildcard '*' when the request's credentials mode is 'include'.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You send cookies. Echo the exact origin, add &lt;code&gt;Vary: Origin&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;...'Access-Control-Allow-Credentials' header in the response is '' which must be 'true'...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;Access-Control-Allow-Credentials: true&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Add the header to &lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Add the method to &lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;...has a value '...' that is not equal to the supplied origin.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allow list has the wrong origin. Check port, scheme, &lt;code&gt;www&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;...contains multiple values '*, *', but only one is allowed.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Both nginx and the app add the header. Remove one.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Finding the preflight in DevTools
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools &lt;strong&gt;before&lt;/strong&gt; reproducing. Tick &lt;strong&gt;Preserve log&lt;/strong&gt; if a redirect clears the list.&lt;/li&gt;
&lt;li&gt;In the Network panel, pick &lt;strong&gt;All&lt;/strong&gt; instead of Fetch/XHR.&lt;/li&gt;
&lt;li&gt;The blocked request shows &lt;code&gt;CORS error&lt;/code&gt; in the Status column (Chrome 88+). Hover it for the reason code, e.g. &lt;code&gt;PreflightInvalidStatus&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The preflight is its own OPTIONS row with Type &lt;code&gt;preflight&lt;/code&gt;. Check its status, then compare &lt;code&gt;Access-Control-Request-Method&lt;/code&gt; / &lt;code&gt;-Headers&lt;/code&gt; in the request with &lt;code&gt;Access-Control-Allow-*&lt;/code&gt; in the response.&lt;/li&gt;
&lt;li&gt;No preflight row? The request was simple, or Chrome used a cached result (&lt;code&gt;Access-Control-Max-Age&lt;/code&gt;, capped at 7200 s in Chrome). Tick &lt;strong&gt;Disable cache&lt;/strong&gt; and retry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I replay the preflight with curl, which shows exactly what the server returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; OPTIONS &lt;span class="s1"&gt;'https://api.example.com/orders'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Origin: http://localhost:5173'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Access-Control-Request-Method: POST'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Access-Control-Request-Headers: authorization, content-type'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To test a fix before touching the server, Chrome 113+ lets you override response headers locally: Network &amp;gt; Headers &amp;gt; Response Headers, edit the value, reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server fixes
&lt;/h2&gt;

&lt;p&gt;Express, mounted before auth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;cors&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;origin&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="s2"&gt;http://localhost:5173&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="s2"&gt;https://app.example.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;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// only if you send cookies&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js App Router route handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/orders/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:5173&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="s2"&gt;https://app.example.com&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="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;origin&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&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="s2"&gt;GET, POST, OPTIONS&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="s2"&gt;Access-Control-Allow-Headers&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="s2"&gt;Content-Type, Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Vary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Origin&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&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="nf"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;origin&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;orders&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="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="nf"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;origin&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;For lots of routes, do it once in &lt;code&gt;proxy.ts&lt;/code&gt; (&lt;code&gt;middleware.ts&lt;/code&gt; before Next 16).&lt;/p&gt;

&lt;p&gt;nginx. The &lt;code&gt;always&lt;/code&gt; matters, because without it your 401s and 500s go out without the header and surface as CORS errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;OPTIONS)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Access-Control-Allow-Origin&lt;/span&gt; &lt;span class="s"&gt;"https://app.example.com"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Access-Control-Allow-Methods&lt;/span&gt; &lt;span class="s"&gt;"GET,&lt;/span&gt; &lt;span class="s"&gt;POST,&lt;/span&gt; &lt;span class="s"&gt;PUT,&lt;/span&gt; &lt;span class="s"&gt;DELETE,&lt;/span&gt; &lt;span class="s"&gt;OPTIONS"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Access-Control-Allow-Headers&lt;/span&gt; &lt;span class="s"&gt;"Authorization,&lt;/span&gt; &lt;span class="s"&gt;Content-Type"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Access-Control-Allow-Origin&lt;/span&gt; &lt;span class="s"&gt;"https://app.example.com"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Vary&lt;/span&gt; &lt;span class="s"&gt;Origin&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&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;
  
  
  On localhost, skip CORS with a proxy
&lt;/h2&gt;

&lt;p&gt;Vite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;proxy&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="s2"&gt;/api&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="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;changeOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/:path*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/:path*&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;fetch("/api/orders")&lt;/code&gt; and the browser never makes a cross-origin request. Remember the Vite proxy is dev-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that look like fixes but aren't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mode: "no-cors"&lt;/code&gt;&lt;/strong&gt; gives you an opaque response: &lt;code&gt;status&lt;/code&gt; 0, no headers, empty body. It also silently drops &lt;code&gt;Authorization&lt;/code&gt; and &lt;code&gt;Content-Type: application/json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--disable-web-security&lt;/code&gt; or a "CORS unblock" extension&lt;/strong&gt; fixes your browser only. Your users still get the error, and your profile is now readable by any site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt;&lt;/strong&gt; is Chrome's default Referrer-Policy (since Chrome 85). It shows up in every request's Headers tab and has nothing to do with CORS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A faster way to find the preflights
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension with its own DevTools panel. In live capture it lists the same requests DevTools records, preflights included, so I filter &lt;code&gt;method:OPTIONS&lt;/code&gt; to see every preflight on the page, or &lt;code&gt;is:error&lt;/code&gt; for failed calls and 4xx/5xx together. Right-click &amp;gt; Copy &amp;gt; Copy as cURL (bash) gives me the call to replay in a terminal, where CORS doesn't apply.&lt;/p&gt;

&lt;p&gt;The full guide, with steps and FAQ: &lt;a href="https://netraptor.dev/guides/fix-cors-error" rel="noopener noreferrer"&gt;What is a CORS error, and how do you fix it?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>api</category>
    </item>
    <item>
      <title>Forgot to open DevTools before the bug? Here's what you can still do</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:56:38 +0000</pubDate>
      <link>https://dev.to/branch42-labs/forgot-to-open-devtools-before-the-bug-heres-what-you-can-still-do-27bp</link>
      <guid>https://dev.to/branch42-labs/forgot-to-open-devtools-before-the-bug-heres-what-you-can-still-do-27bp</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; You can't see requests that happened before you opened DevTools. Chrome's Network panel only records while DevTools is open, so they were never logged. What you can do is set things up so the &lt;em&gt;next&lt;/em&gt; one gets caught: reproduce with DevTools open, launch Chrome with &lt;code&gt;--auto-open-devtools-for-tabs&lt;/code&gt;, record the whole browser with &lt;code&gt;chrome://net-export&lt;/code&gt;, or check the server logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment
&lt;/h2&gt;

&lt;p&gt;A one-off 500 flashes up. Or the login redirect loops once and then works. You hit F12, click Network, and the panel is empty.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.chrome.com/docs/devtools/network/reference#record" rel="noopener noreferrer"&gt;Chrome docs&lt;/a&gt; are clear that DevTools records network requests "so long as DevTools is open." There's no background buffer to recover from. Browser history has the page URL, not the API calls the page made.&lt;/p&gt;

&lt;p&gt;So the job becomes catching it next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, make sure the tab is really empty for that reason
&lt;/h2&gt;

&lt;p&gt;I've lost time to these more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A filter is still on.&lt;/strong&gt; Something in the filter box, a type chip other than All, Invert ticked, or Hide data URLs / Hide extension URLs under More filters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording is off.&lt;/strong&gt; The round button at the left of the action bar should be red. &lt;code&gt;Cmd+E&lt;/code&gt; / &lt;code&gt;Ctrl+E&lt;/code&gt; toggles it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong tab.&lt;/strong&gt; A link that opened a new tab or popup has its own DevTools. Iframe requests do show in the parent page's panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A service worker is involved.&lt;/strong&gt; It may have answered from its cache, or sent the request itself (background sync and similar). Inspect the worker from &lt;code&gt;chrome://inspect/#service-workers&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those apply, the request ran before DevTools opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: reproduce with DevTools open
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools on the tab and go to Network.&lt;/li&gt;
&lt;li&gt;Tick &lt;strong&gt;Preserve log&lt;/strong&gt; so redirects don't wipe the list.&lt;/li&gt;
&lt;li&gt;Tick &lt;strong&gt;Disable cache&lt;/strong&gt; if the bug only happens on a cold load.&lt;/li&gt;
&lt;li&gt;Reload or repeat the steps.&lt;/li&gt;
&lt;li&gt;Export a HAR before closing DevTools, because the log dies with the window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wrote up &lt;a href="https://netraptor.dev/guides/preserve-network-log-chrome" rel="noopener noreferrer"&gt;Preserve log and its limits&lt;/a&gt; separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: let Chrome open DevTools for you
&lt;/h2&gt;

&lt;p&gt;Quit Chrome fully, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
open &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Google Chrome"&lt;/span&gt; &lt;span class="nt"&gt;--args&lt;/span&gt; &lt;span class="nt"&gt;--auto-open-devtools-for-tabs&lt;/span&gt;

&lt;span class="c"&gt;# Linux&lt;/span&gt;
google-chrome &lt;span class="nt"&gt;--auto-open-devtools-for-tabs&lt;/span&gt;

&lt;span class="c"&gt;# Windows&lt;/span&gt;
&lt;span class="s2"&gt;"C:&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;rogram Files&lt;/span&gt;&lt;span class="se"&gt;\G&lt;/span&gt;&lt;span class="s2"&gt;oogle&lt;/span&gt;&lt;span class="se"&gt;\C&lt;/span&gt;&lt;span class="s2"&gt;hrome&lt;/span&gt;&lt;span class="se"&gt;\A&lt;/span&gt;&lt;span class="s2"&gt;pplication&lt;/span&gt;&lt;span class="se"&gt;\c&lt;/span&gt;&lt;span class="s2"&gt;hrome.exe"&lt;/span&gt; &lt;span class="nt"&gt;--auto-open-devtools-for-tabs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new tab now opens with DevTools attached, so first-load requests are recorded. The switch is ignored if Chrome is already running. I add &lt;code&gt;--user-data-dir=/tmp/chrome-debug&lt;/code&gt; so it gets its own profile and my normal windows stay normal.&lt;/p&gt;

&lt;p&gt;If the problem is only popups (OAuth, payment windows), there's a setting for that: DevTools Settings &amp;gt; Preferences &amp;gt; Global &amp;gt; &lt;strong&gt;Auto-open DevTools for popups&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: chrome://net-export
&lt;/h2&gt;

&lt;p&gt;This records a NetLog for the whole browser, no DevTools needed. The steps from &lt;a href="https://www.chromium.org/for-testers/providing-network-details/" rel="noopener noreferrer"&gt;Chromium's docs&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;chrome://net-export&lt;/code&gt; in a new tab.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Start Logging to Disk&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Reproduce in another tab. Leave the net-export tab open or logging stops.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Stop Logging&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It also only records from when you press Start. On days I'm chasing something intermittent, I start it in the morning and leave it running. Cap the size with &lt;code&gt;--net-log-max-size-mb&lt;/code&gt; if that worries you.&lt;/p&gt;

&lt;p&gt;The default level strips cookies and credentials. &lt;strong&gt;Include raw bytes&lt;/strong&gt; adds the actual bytes, which can contain personal data.&lt;/p&gt;

&lt;p&gt;Open the file in the &lt;a href="https://netlog-viewer.appspot.com/" rel="noopener noreferrer"&gt;NetLog viewer&lt;/a&gt;. It isn't a HAR, and it reads more like a connection-level trace (DNS, TLS, sockets) than a request inspector. Great for "why did this connection fail", less great for "what JSON came back".&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: the server already logged it
&lt;/h2&gt;

&lt;p&gt;If it already happened and won't happen again, check access logs, your error tracker or APM, and traces around that timestamp. If your API returns a request ID header, start logging it on the client too, so the next report maps straight to a server line.&lt;/p&gt;

&lt;p&gt;The gap is requests that never reached the server: CORS failures, blocked calls, client timeouts. For those you need one of the browser options.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;For apps I work on every day, I don't want to depend on remembering F12. I use &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension I'm building, and turn on &lt;strong&gt;Keep history for this site&lt;/strong&gt; for those apps. It hooks &lt;code&gt;fetch&lt;/code&gt; and XHR in the page from the start of the load and saves those calls to IndexedDB with DevTools closed. When a 500 shows up, I open the panel, pick the saved session and filter &lt;code&gt;status-code:5xx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It only records API calls (no documents, scripts or images), only on sites I've turned it on for, and only from that point on. So it won't rescue yesterday's bug, but it does catch the next one.&lt;/p&gt;

&lt;p&gt;Full guide, with the Network-tab-empty checklist and FAQ: &lt;a href="https://netraptor.dev/guides/see-requests-before-devtools-opened" rel="noopener noreferrer"&gt;https://netraptor.dev/guides/see-requests-before-devtools-opened&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>chrome</category>
      <category>devtools</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why my coding agent couldn't see my app's API calls, and the 3 fixes I use</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:56:10 +0000</pubDate>
      <link>https://dev.to/branch42-labs/why-my-coding-agent-couldnt-see-my-apps-api-calls-and-the-3-fixes-i-use-4705</link>
      <guid>https://dev.to/branch42-labs/why-my-coding-agent-couldnt-see-my-apps-api-calls-and-the-3-fixes-i-use-4705</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Your agent runs in a terminal or editor. Your app's API calls run in a Chrome tab, behind your login. Nothing connects the two until you add something. You have three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Paste it&lt;/strong&gt; (Copy as cURL or HAR). Zero setup. Manual, and it leaks tokens if you don't clean it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the agent a browser&lt;/strong&gt; (Chrome DevTools MCP, Playwright MCP, Claude in Chrome, Cursor's browser). Best for reproducing bugs. Sees only what it loads in that session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let it read traffic you already recorded&lt;/strong&gt; through a local MCP server. Best for writing code against the real API. Read-only, covers only what you recorded.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;I kept asking Claude Code to write a client for an internal API nobody had documented. It read the frontend source, found the URLs, and invented the response shapes. Then it tried &lt;code&gt;curl&lt;/code&gt; from the terminal and got a 401, because the terminal doesn't have my session cookies.&lt;/p&gt;

&lt;p&gt;The agent did nothing wrong. The data it needed lived in a browser tab it had no way into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: paste the request
&lt;/h2&gt;

&lt;p&gt;For one failing call this is still the fastest thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;DevTools → Network, reproduce the problem.&lt;/li&gt;
&lt;li&gt;Right-click the request → Copy → Copy as cURL.&lt;/li&gt;
&lt;li&gt;Delete the &lt;code&gt;Cookie&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt; values.&lt;/li&gt;
&lt;li&gt;Paste it into the chat with the response body.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Watch out for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Copy as cURL includes your headers, so tokens go straight into the chat unless you remove them.&lt;/li&gt;
&lt;li&gt;Chrome's default HAR export is sanitized, but that only drops &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;Set-Cookie&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt;. Tokens in URLs, custom headers like &lt;code&gt;X-Api-Key&lt;/code&gt; and bodies stay in.&lt;/li&gt;
&lt;li&gt;A HAR of a few minutes of clicking has hundreds of entries. That's a lot of context spent on images and scripts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 2: give the agent its own browser
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Chrome DevTools MCP (network, console, perf traces, Lighthouse)&lt;/span&gt;
claude mcp add chrome-devtools &lt;span class="nt"&gt;--scope&lt;/span&gt; user &lt;span class="nt"&gt;--&lt;/span&gt; npx chrome-devtools-mcp@latest

&lt;span class="c"&gt;# Playwright MCP (multi-browser, accessibility tree, request mocking)&lt;/span&gt;
claude mcp add playwright npx @playwright/mcp@latest

&lt;span class="c"&gt;# Claude in Chrome extension: uses your Chrome and its login state&lt;/span&gt;
claude &lt;span class="nt"&gt;--chrome&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Cursor, the built-in browser in Agent already reads console messages and monitors network requests for the pages it opens.&lt;/p&gt;

&lt;p&gt;This is the one I reach for when the agent has to &lt;em&gt;do&lt;/em&gt; something: reproduce a bug, check a UI fix, read console errors, profile a slow page. It's also the only option that can see a page nobody has opened yet.&lt;/p&gt;

&lt;p&gt;Where it gets awkward is "tell me about the whole API":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The request list is per page. Chrome DevTools MCP resets it on navigation and &lt;code&gt;includePreservedRequests&lt;/code&gt; reaches back 3 navigations.&lt;/li&gt;
&lt;li&gt;DevTools MCP and Playwright MCP start their own browser profile, so logged-in pages mean logging in there or attaching to your real session.&lt;/li&gt;
&lt;li&gt;Every page the agent walks costs tool calls and tokens.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/orders/1042&lt;/code&gt; and &lt;code&gt;/orders/1043&lt;/code&gt; come back as two unrelated URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote up DevTools MCP setup and attach mode separately: &lt;a href="https://netraptor.dev/guides/chrome-devtools-mcp" rel="noopener noreferrer"&gt;Chrome DevTools MCP guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: let the agent read what you already recorded
&lt;/h2&gt;

&lt;p&gt;The other way round: you use the app normally, something records the calls, and the agent queries that recording.&lt;/p&gt;

&lt;p&gt;This is what I built &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt; for. It's a Chrome extension that records your app's fetch/XHR calls in your own signed-in Chrome (turn on &lt;strong&gt;Keep history for this site&lt;/strong&gt; so sessions are saved). Pro includes a local, read-only MCP server with five tools: &lt;code&gt;list_sessions&lt;/code&gt;, &lt;code&gt;list_endpoints&lt;/code&gt;, &lt;code&gt;get_endpoint&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt; and &lt;code&gt;get_request&lt;/code&gt;. Endpoints come back as templates like &lt;code&gt;/orders/{id}&lt;/code&gt; with an inferred schema and a couple of real examples.&lt;/p&gt;

&lt;p&gt;Pair once from Settings → Local MCP server (it gives you an &lt;code&gt;npx -y @branch42-labs/netraptor-mcp pair NR-…&lt;/code&gt; command), then add it to your client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Claude Code&lt;/span&gt;
claude mcp add netraptor &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @branch42-labs/netraptor-mcp

&lt;span class="c"&gt;# Codex (CLI and IDE extension share ~/.codex/config.toml)&lt;/span&gt;
codex mcp add netraptor &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @branch42-labs/netraptor-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Cursor:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;~/.cursor/mcp.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.cursor/mcp.json&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;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"netraptor"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@branch42-labs/netraptor-mcp"&lt;/span&gt;&lt;span class="p"&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;span class="p"&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;Honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only reads saved sessions. Live traffic in the DevTools panel without Keep history isn't visible to the agent.&lt;/li&gt;
&lt;li&gt;Sensitive headers, URL params and top-level JSON fields (&lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Cookie&lt;/code&gt;, anything named like &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;session&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;) are redacted. Nested JSON fields aren't, so I use a test account.&lt;/li&gt;
&lt;li&gt;It records from inside the page, so browser-added headers like &lt;code&gt;Cookie&lt;/code&gt; aren't there at all. DevTools MCP shows them.&lt;/li&gt;
&lt;li&gt;It can't click or send anything. If you never opened a page, its calls aren't there.&lt;/li&gt;
&lt;li&gt;The MCP server is Pro ($29.99 once). Recording and Postman/HAR/curl export are free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prompts that work well once it's connected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"What does GET /orders/{id} return? Show me a real response."&lt;/li&gt;
&lt;li&gt;"Write a typed client for the endpoints the checkout page calls."&lt;/li&gt;
&lt;li&gt;"Which calls returned 401 in my last session, and what did the token refresh request return just before?"&lt;/li&gt;
&lt;li&gt;"Generate a mock server for /api/billing from the recorded responses."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I actually do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One request, one question: Copy as cURL, minus tokens.&lt;/li&gt;
&lt;li&gt;Reproduce or verify: Chrome DevTools MCP, or Playwright MCP for flows and other browsers.&lt;/li&gt;
&lt;li&gt;Write code against the real API: recorded traffic over MCP.&lt;/li&gt;
&lt;li&gt;Usually both: recorded traffic tells the agent what the API looks like, and the browser server lets it check the fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full comparison, with setup for each client and an FAQ, is on the original guide: &lt;a href="https://netraptor.dev/guides/coding-agent-see-api-calls" rel="noopener noreferrer"&gt;Why your coding agent can't see your app's API calls&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>webdev</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Chrome DevTools MCP in Claude Code (setup, attach mode, vs Playwright MCP)</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:55:21 +0000</pubDate>
      <link>https://dev.to/branch42-labs/chrome-devtools-mcp-in-claude-code-setup-attach-mode-vs-playwright-mcp-36o9</link>
      <guid>https://dev.to/branch42-labs/chrome-devtools-mcp-in-claude-code-setup-attach-mode-vs-playwright-mcp-36o9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Chrome DevTools MCP is the Chrome DevTools team's open-source MCP server. It lets Claude Code, Cursor, Codex or Gemini CLI drive a real Chrome and use DevTools features: network requests, console, screenshots, performance traces, Lighthouse and heap snapshots. Install it with &lt;code&gt;claude mcp add chrome-devtools --scope user -- npx chrome-devtools-mcp@latest&lt;/code&gt;. Add &lt;code&gt;--autoConnect&lt;/code&gt; (Chrome 144+) to let it attach to the Chrome you're already signed in to. It's great for debugging the page in front of it. It's a poor fit for "map this app's whole API".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wanted it
&lt;/h2&gt;

&lt;p&gt;My agent kept writing frontend fixes it couldn't check. With a browser MCP server it opens the page, reads the console, looks at the failing request and tells me what broke. The Chrome team shipped this as a public preview in &lt;a href="https://developer.chrome.com/blog/chrome-devtools-mcp" rel="noopener noreferrer"&gt;September 2025&lt;/a&gt;, and it now has around 60 tools (some behind opt-in flags).&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;You need Node.js LTS and current stable Chrome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add chrome-devtools &lt;span class="nt"&gt;--scope&lt;/span&gt; user &lt;span class="nt"&gt;--&lt;/span&gt; npx chrome-devtools-mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the plugin, which also installs skills for a11y, cookies, LCP and memory leaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add ChromeDevTools/chrome-devtools-mcp
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;chrome-devtools-mcp@chrome-devtools-plugins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cursor&lt;/strong&gt; (Settings → MCP → New MCP Server) and most other clients:&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;"mcpServers"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"chrome-devtools"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chrome-devtools-mcp@latest"&lt;/span&gt;&lt;span class="p"&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;span class="p"&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;&lt;strong&gt;Codex / Gemini CLI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;codex mcp add chrome-devtools &lt;span class="nt"&gt;--&lt;/span&gt; npx chrome-devtools-mcp@latest
gemini mcp add chrome-devtools npx chrome-devtools-mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First prompt to try: &lt;code&gt;Check the performance of https://developer.chrome.com&lt;/code&gt;. You'll see a Chrome window open and a trace get recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network tools
&lt;/h2&gt;

&lt;p&gt;There are two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_network_requests&lt;/code&gt;: requests for a page since the last navigation. Filter by &lt;code&gt;resourceTypes&lt;/code&gt;, paginate with &lt;code&gt;pageSize&lt;/code&gt;/&lt;code&gt;pageIdx&lt;/code&gt;, and set &lt;code&gt;includePreservedRequests&lt;/code&gt; to reach back 3 navigations.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_network_request&lt;/code&gt;: one request with headers (including &lt;code&gt;Cookie&lt;/code&gt; and &lt;code&gt;Set-Cookie&lt;/code&gt;) and bodies. &lt;code&gt;responseFilePath&lt;/code&gt; writes a big body to disk instead of into the context window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trick I like most: call &lt;code&gt;get_network_request&lt;/code&gt; with no &lt;code&gt;reqid&lt;/code&gt; and it returns whatever request you have &lt;strong&gt;selected in the DevTools Network panel&lt;/strong&gt;. I click the broken call, then type "why did this 403?".&lt;/p&gt;

&lt;p&gt;If headers carry tokens you'd rather not send to a model, start the server with &lt;code&gt;--redactNetworkHeaders&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attach mode (your signed-in browser)
&lt;/h2&gt;

&lt;p&gt;By default the server launches Chrome with its own profile in &lt;code&gt;~/.cache/chrome-devtools-mcp/chrome-profile&lt;/code&gt;. &lt;code&gt;--isolated&lt;/code&gt; uses a throwaway profile instead. To debug something behind your real login:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;chrome://inspect/#remote-debugging&lt;/code&gt; in Chrome 144+ and allow incoming connections.&lt;/li&gt;
&lt;li&gt;Add the flag:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chrome-devtools-mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--autoConnect"&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;ol&gt;
&lt;li&gt;Approve the permission dialog Chrome shows each time the server connects. A "Chrome is being controlled by automated test software" banner stays up while it's attached.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No &lt;code&gt;--autoConnect&lt;/code&gt; (sandboxes, older Chrome)? Start Chrome yourself and point the server at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/Applications/Google&lt;span class="se"&gt;\ &lt;/span&gt;Chrome.app/Contents/MacOS/Google&lt;span class="se"&gt;\ &lt;/span&gt;Chrome &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-debugging-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9222 &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/chrome-profile-stable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"chrome-devtools-mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--browser-url=http://127.0.0.1:9222"&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;Heads up: attach mode gives the agent your cookies and signed-in accounts. Google's own docs say to only use it with agents you trust. Also, usage statistics are on by default (&lt;code&gt;--no-usage-statistics&lt;/code&gt;), and performance tools can send trace URLs to CrUX (&lt;code&gt;--no-performance-crux&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome DevTools MCP vs Playwright MCP
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/microsoft/playwright-mcp" rel="noopener noreferrer"&gt;Playwright MCP&lt;/a&gt; (&lt;code&gt;claude mcp add playwright npx @playwright/mcp@latest&lt;/code&gt;) overlaps a lot. How I pick:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Chrome DevTools MCP&lt;/th&gt;
&lt;th&gt;Playwright MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browsers&lt;/td&gt;
&lt;td&gt;Chrome, Chrome for Testing&lt;/td&gt;
&lt;td&gt;Chrome, Firefox, WebKit, Edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page model&lt;/td&gt;
&lt;td&gt;screenshots + text snapshot&lt;/td&gt;
&lt;td&gt;accessibility tree, no vision needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perf insights / Lighthouse / heap&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Playwright traces via &lt;code&gt;--caps=devtools&lt;/code&gt;, no Lighthouse or heap tools listed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;list + get (headers, bodies)&lt;/td&gt;
&lt;td&gt;list (URL regex) + get, mocking via &lt;code&gt;--caps=network&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing browser&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--autoConnect&lt;/code&gt;, &lt;code&gt;--browser-url&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--extension&lt;/code&gt;, &lt;code&gt;--cdp-endpoint&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;User flows and cross-browser checks: Playwright. "Why is this page slow or erroring in Chrome": DevTools MCP. Playwright's README also notes that coding agents may get on better with its CLI + skills, since those calls cost fewer tokens than MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops for API work
&lt;/h2&gt;

&lt;p&gt;Both servers only see what the browser loads while the agent is connected. When I want the agent to understand the whole API of an app, that hurts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The request list resets per navigation (3 back at most), so the agent has to click through every screen.&lt;/li&gt;
&lt;li&gt;Each screen costs tool calls and context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/orders/1042&lt;/code&gt; and &lt;code&gt;/orders/1043&lt;/code&gt; come back as separate URLs, so the agent has to figure out they're one endpoint.&lt;/li&gt;
&lt;li&gt;Real logged-in traffic means attach mode and handing over my session.&lt;/li&gt;
&lt;li&gt;Close the browser and the traffic is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For that job I use &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension I work on. It records the app's fetch/XHR calls while I browse normally (or crawls same-site pages), groups them into endpoints like &lt;code&gt;/orders/{id}&lt;/code&gt;, and its Pro plan has a local, read-only MCP server so the agent can ask "what does &lt;code&gt;GET /orders/{id}&lt;/code&gt; return?" from real recorded responses. It has its own gap: it records from inside the page, so browser-added headers like &lt;code&gt;Cookie&lt;/code&gt; aren't in its copy. DevTools MCP shows those. So I run both: Netraptor for "what does the API look like", DevTools MCP for "go reproduce this".&lt;/p&gt;

&lt;p&gt;The full guide, with links to every doc above, is at &lt;a href="https://netraptor.dev/guides/chrome-devtools-mcp" rel="noopener noreferrer"&gt;netraptor.dev/guides/chrome-devtools-mcp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>chrome</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Chrome won't let you paste into the console? Type "allow pasting" (no quotes)</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:55:06 +0000</pubDate>
      <link>https://dev.to/branch42-labs/chrome-wont-let-you-paste-into-the-console-type-allow-pasting-no-quotes-3fjh</link>
      <guid>https://dev.to/branch42-labs/chrome-wont-let-you-paste-into-the-console-type-allow-pasting-no-quotes-3fjh</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Paste once so the warning shows up. Then type &lt;code&gt;allow pasting&lt;/code&gt; in the Console prompt, without quotes, and press Enter. Paste again. Chrome remembers the choice for that profile.&lt;/p&gt;

&lt;p&gt;I set up a fresh Chrome profile last week, pasted a snippet into the Console, and got this instead of my code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: Don't paste code into the DevTools Console that you don't understand or haven't reviewed yourself. This could allow attackers to steal your identity or take control of your computer. Type "allow pasting" below and press Enter to allow pasting.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you landed here from Google with that exact text in the search box, here is the short version and the parts that trip people up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the Console (&lt;code&gt;Ctrl+Shift+J&lt;/code&gt;, or &lt;code&gt;Cmd+Option+J&lt;/code&gt; on a Mac).&lt;/li&gt;
&lt;li&gt;Paste your code. Chrome blocks it and logs the warning.&lt;/li&gt;
&lt;li&gt;Type &lt;code&gt;allow pasting&lt;/code&gt; and press Enter.&lt;/li&gt;
&lt;li&gt;Paste again. It lands in the prompt this time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the Sources panel (for example a snippet under Sources &amp;gt; Snippets) you get a dialog with a text field instead of a Console message. Type the same phrase there and confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "allow pasting" sometimes doesn't work
&lt;/h2&gt;

&lt;p&gt;I looked at the DevTools source to see what it checks. It compares the prompt text against the phrase exactly, and only while the warning is active. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Paste first.&lt;/strong&gt; Typing the phrase before the warning appears does nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No double quotes.&lt;/strong&gt; The warning shows the phrase in double quotes, but &lt;code&gt;"allow pasting"&lt;/code&gt; is just a JS string. The Console echoes it and you are still blocked. Plain &lt;code&gt;allow pasting&lt;/code&gt; works, and so does &lt;code&gt;'allow pasting'&lt;/code&gt; with single quotes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No extra spaces or capitals.&lt;/strong&gt; Watch for autocomplete adding things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-English DevTools&lt;/strong&gt; uses a translated phrase. Type the one in your warning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you don't see it on your main profile
&lt;/h2&gt;

&lt;p&gt;Chrome only shows the warning to profiles that look new to DevTools. If your Console history has at least 5 entries, it skips the warning entirely. The Chrome team wrote this up in &lt;a href="https://developer.chrome.com/blog/self-xss" rel="noopener noreferrer"&gt;How Chrome DevTools helps to defend against self-XSS attacks&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It comes back when the stored setting is gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a new Chrome profile or a fresh install&lt;/li&gt;
&lt;li&gt;Settings &amp;gt; Preferences &amp;gt; &lt;strong&gt;Restore defaults and reload&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a rare bug mentioned in the Chrome post, where a new profile is the suggested fix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For CI and automation, launch Chrome with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--unsafely-disable-devtools-self-xss-warnings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.chrome.com/docs/automation-and-testing/chrome-for-testing" rel="noopener noreferrer"&gt;Chrome for Testing&lt;/a&gt; already has the warnings off. Edge is Chromium, so it behaves the same and takes the same flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reason it exists: self-XSS
&lt;/h2&gt;

&lt;p&gt;Self-XSS is the "paste this in your console to get free credits" scam. Whatever you paste runs as you on that site. It can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;read &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;sessionStorage&lt;/code&gt; and any cookie that isn't &lt;code&gt;HttpOnly&lt;/code&gt;, which is where a lot of apps keep tokens&lt;/li&gt;
&lt;li&gt;call the site's API with your session&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch&lt;/code&gt; all of it off to someone else's server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For us the phrase is a one-time speed bump. The habit worth keeping is reading every paste before you hit Enter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safer habits for code you copied
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use a Snippet for anything you run twice.&lt;/strong&gt; Sources &amp;gt; Snippets &amp;gt; New snippet, paste, save, then &lt;code&gt;Ctrl+Enter&lt;/code&gt; / &lt;code&gt;Cmd+Enter&lt;/code&gt;. You can also run it from the Command Menu (&lt;code&gt;Ctrl+Shift+P&lt;/code&gt;, delete the &lt;code&gt;&amp;gt;&lt;/code&gt;, type &lt;code&gt;!snippet-name&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read a copied fetch before running it.&lt;/strong&gt; Check that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the URL is the host you expect&lt;/li&gt;
&lt;li&gt;the method is what you want (&lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; change data)&lt;/li&gt;
&lt;li&gt;the body is readable JSON or form data&lt;/li&gt;
&lt;li&gt;nothing extra wraps the call, like &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;atob&lt;/code&gt;, or a second &lt;code&gt;fetch&lt;/code&gt; that ships &lt;code&gt;document.cookie&lt;/code&gt; somewhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prefer cURL in a terminal if you only need the response.&lt;/strong&gt; It runs outside the page, so it can't touch page storage. It still carries your cookies and auth headers, so don't drop it into Slack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skipping the paste entirely
&lt;/h2&gt;

&lt;p&gt;Half the time I'm pasting because I copied a request as fetch and want to replay it with a tweak. I use &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt; for that now (disclosure: it's ours). It's a DevTools panel where you right-click a request and pick &lt;strong&gt;Resend&lt;/strong&gt;, or &lt;strong&gt;Edit and resend as fetch&lt;/strong&gt; to change the method, URL, headers or body first. It runs the request with &lt;code&gt;fetch&lt;/code&gt; inside the inspected page using the page's cookies, and the result shows up tagged &lt;code&gt;Resent&lt;/code&gt;. The same menu copies as cURL (bash/cmd), PowerShell, fetch or Node.js fetch. It only works on live traffic, since it needs the page you're inspecting.&lt;/p&gt;

&lt;p&gt;Full guide, with the Sources dialog and FAQ: &lt;a href="https://netraptor.dev/guides/allow-pasting-chrome-console" rel="noopener noreferrer"&gt;Allow pasting in the Chrome console&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>devtools</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>How to create a HAR file in Chrome without leaking your tokens</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:54:55 +0000</pubDate>
      <link>https://dev.to/branch42-labs/how-to-create-a-har-file-in-chrome-without-leaking-your-tokens-3n68</link>
      <guid>https://dev.to/branch42-labs/how-to-create-a-har-file-in-chrome-without-leaking-your-tokens-3n68</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Open DevTools &amp;gt; Network, check &lt;strong&gt;Preserve log&lt;/strong&gt; and &lt;strong&gt;Disable cache&lt;/strong&gt;, clear, reload, reproduce the bug, then click &lt;strong&gt;Export HAR (sanitized)...&lt;/strong&gt;. Since Chrome 130 that export drops &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;Set-Cookie&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt;. Tokens in URLs, custom headers like &lt;code&gt;X-Api-Key&lt;/code&gt;, and request and response bodies are still in the file. Run a &lt;code&gt;grep&lt;/code&gt; over it before you attach it to a ticket.&lt;/p&gt;

&lt;p&gt;Every support team eventually asks for "a HAR file". Most of the bad ones I've seen came from the same three mistakes: DevTools opened too late, Preserve log off, or a filter left on. Here's the version that works on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to the page with the problem and open DevTools (&lt;code&gt;F12&lt;/code&gt;, &lt;code&gt;Ctrl+Shift+I&lt;/code&gt;, or &lt;code&gt;Cmd+Option+I&lt;/code&gt; on macOS). Click &lt;strong&gt;Network&lt;/strong&gt;. Do this &lt;em&gt;before&lt;/em&gt; you reproduce anything, because Chrome only logs requests while DevTools is open.&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;Preserve log&lt;/strong&gt; (so redirects and logins don't wipe the list) and &lt;strong&gt;Disable cache&lt;/strong&gt; (so you see what a first-time visitor sees).&lt;/li&gt;
&lt;li&gt;Click the clear button, then reload. Now the log starts from a clean page load.&lt;/li&gt;
&lt;li&gt;Reproduce the bug. Keep DevTools open until it has happened.&lt;/li&gt;
&lt;li&gt;Clear the Filter box and select &lt;strong&gt;All&lt;/strong&gt;. A leftover &lt;code&gt;Fetch/XHR&lt;/code&gt; chip is an easy way to ship half a file.&lt;/li&gt;
&lt;li&gt;Click the download icon, &lt;strong&gt;Export HAR (sanitized)...&lt;/strong&gt;, and save. Right-clicking any request gives you &lt;strong&gt;Save all as HAR (sanitized)&lt;/strong&gt; too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the flow opens a popup or a new tab (hello, SSO), those requests land in &lt;em&gt;that&lt;/em&gt; tab's DevTools. Turn on &lt;strong&gt;Auto-open DevTools for popups&lt;/strong&gt; in DevTools settings first.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Sanitized" covers three headers
&lt;/h2&gt;

&lt;p&gt;Chrome 130 changed the default export (&lt;a href="https://developer.chrome.com/blog/new-in-devtools-130" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;). The sanitized file leaves out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Cookie&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Set-Cookie&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Authorization&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole list. If you actually need those headers, say to debug your own auth flow, go to DevTools &lt;strong&gt;Settings &amp;gt; Preferences &amp;gt; Network&lt;/strong&gt;, check &lt;strong&gt;Allow to generate HAR with sensitive data&lt;/strong&gt;, then long-click the Export button and pick &lt;strong&gt;Export HAR (with sensitive data)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Edge behaves the same since it's the same DevTools. Firefox (Network Monitor gear menu &amp;gt; &lt;strong&gt;Persist Logs&lt;/strong&gt;, then &lt;strong&gt;Save All As HAR&lt;/strong&gt;) and Safari (Web Inspector &amp;gt; Network &amp;gt; &lt;strong&gt;Preserve Log&lt;/strong&gt;, then &lt;strong&gt;Export&lt;/strong&gt;) put cookies and auth headers in the file along with everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still in a sanitized HAR
&lt;/h2&gt;

&lt;p&gt;Things I've found in "sanitized" HAR files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;X-Api-Key&lt;/code&gt;, &lt;code&gt;X-CSRF-Token&lt;/code&gt;, &lt;code&gt;X-Auth-Token&lt;/code&gt; headers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;?access_token=...&lt;/code&gt; and &lt;code&gt;?api_key=...&lt;/code&gt; in URLs, including inside &lt;code&gt;Referer&lt;/code&gt; and &lt;code&gt;Location&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OAuth &lt;code&gt;?code=&lt;/code&gt; values on the login redirect&lt;/li&gt;
&lt;li&gt;The password in a login request body, in plain text&lt;/li&gt;
&lt;li&gt;Refresh tokens in token-exchange calls and fresh access tokens in login responses&lt;/li&gt;
&lt;li&gt;Names, emails and addresses in ordinary API responses&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scrubbing it
&lt;/h2&gt;

&lt;p&gt;First, record less. Log in, clear the log, then reproduce only the failing steps.&lt;/p&gt;

&lt;p&gt;Then strip the headers you know about. With &lt;code&gt;jq&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="s1"&gt;'.log.entries[] |= (
  .request.headers |= map(select(.name | test("^(cookie|authorization|x-api-key)$"; "i") | not))
  | .response.headers |= map(select(.name | test("^set-cookie$"; "i") | not))
  | .request.cookies = []
  | .response.cookies = []
)'&lt;/span&gt; session.har &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; session.scrubbed.har
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Headers are the easy part. Now look for everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'token|secret|password|api[_-]?key|bearer|eyJ'&lt;/span&gt; session.scrubbed.har
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;eyJ&lt;/code&gt; catches JWTs, because &lt;code&gt;{"&lt;/code&gt; base64-encodes to &lt;code&gt;eyJ&lt;/code&gt;. Every hit is a value to replace by hand or a step to leave out of the recording. Google's &lt;a href="https://toolbox.googleapps.com/apps/har_analyzer/" rel="noopener noreferrer"&gt;HAR Analyzer&lt;/a&gt; can also download a redacted copy. Whatever you couldn't remove, log out or rotate the key once the ticket is closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sanitizer that also reads URLs and bodies
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension that adds its own Network panel to DevTools, and its &lt;strong&gt;Save all as HAR (sanitized)&lt;/strong&gt; goes further than Chrome's. It drops any header with a credential-like name (token, secret, password, session, auth, csrf, jwt, key, and so on), sets matching query and fragment params plus OAuth &lt;code&gt;code&lt;/code&gt; to &lt;code&gt;[redacted]&lt;/code&gt;, redacts matching keys at any depth in JSON and form bodies, and removes bodies it can't parse (HTML, text, binary) with a comment saying so. It saves only the rows you have filtered to, so you can export just the failing calls. It matches on field names, so an email under &lt;code&gt;email&lt;/code&gt; or a token in a URL path still gets through. Run the &lt;code&gt;grep&lt;/code&gt; anyway.&lt;/p&gt;

&lt;p&gt;The full guide, with FAQ and a section on why HAR files come out empty, is here: &lt;a href="https://netraptor.dev/guides/create-har-file-in-chrome" rel="noopener noreferrer"&gt;How to create a HAR file in Chrome&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>devtools</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Chrome's Network tab keeps clearing on reload. Here's how to stop it</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:54:33 +0000</pubDate>
      <link>https://dev.to/branch42-labs/chromes-network-tab-keeps-clearing-on-reload-heres-how-to-stop-it-1f30</link>
      <guid>https://dev.to/branch42-labs/chromes-network-tab-keeps-clearing-on-reload-heres-how-to-stop-it-1f30</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; In Chrome DevTools, open the Network panel and tick &lt;strong&gt;Preserve log&lt;/strong&gt;. Requests then survive reloads and redirects. They still vanish when you close DevTools, anything before DevTools opened is missing, and every page load lands in one unlabelled list. In Firefox the option is &lt;strong&gt;Persist Logs&lt;/strong&gt;, under the gear icon in the Network Monitor.&lt;/p&gt;

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

&lt;p&gt;You're debugging a login. The form posts, the server redirects, and the request you needed is gone. Chrome's Network panel clears its list every time the main frame loads a new document. That covers reloads, links, form posts and server redirects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn on Preserve log
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools &lt;strong&gt;before&lt;/strong&gt; loading the page (&lt;code&gt;F12&lt;/code&gt;, or &lt;code&gt;Cmd+Option+I&lt;/code&gt; on macOS). Chrome only records requests while DevTools is open.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Network&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Tick &lt;strong&gt;Preserve log&lt;/strong&gt; in the action bar at the top.&lt;/li&gt;
&lt;li&gt;Reproduce the reload or redirect. The old requests stay put and new ones are added below them.&lt;/li&gt;
&lt;li&gt;Before you close DevTools, export a HAR (download icon in the action bar) if you want to keep any of it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Console has its own Preserve log in its settings. Turn that on too if you want console messages to survive the reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it falls short
&lt;/h2&gt;

&lt;p&gt;I hit these regularly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closing DevTools wipes it.&lt;/strong&gt; The preserved log lives in the DevTools window. Close it and the log is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first load is often missing.&lt;/strong&gt; If the page loaded before you opened DevTools, those requests were never recorded. Reload with DevTools open. For popups, start Chrome with:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  google-chrome &lt;span class="nt"&gt;--auto-open-devtools-for-tabs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need a capture that doesn't depend on DevTools at all, &lt;code&gt;chrome://net-export&lt;/code&gt; writes a browser-level NetLog.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One flat list.&lt;/strong&gt; After five reloads you have five copies of &lt;code&gt;/api/me&lt;/code&gt; and nothing telling you which load each one belongs to. You end up reading the order of the document requests and the waterfall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SPAs never clear anyway.&lt;/strong&gt; A &lt;code&gt;history.pushState&lt;/code&gt; route change isn't a document load, so the log never clears. With or without Preserve log, every route's API calls pile into the same list with no marker between them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disable cache is a different job
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Disable cache&lt;/strong&gt; sits right next to it. Preserve log decides whether the list survives a reload. Disable cache decides whether the requests in it hit the network or come from the HTTP cache (only while DevTools is open). If a request seems missing after a reload, check whether it came from the memory or disk cache before you assume it never ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firefox: Persist Logs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the Network Monitor (&lt;code&gt;Ctrl+Shift+E&lt;/code&gt;, or &lt;code&gt;Cmd+Option+E&lt;/code&gt; on macOS).&lt;/li&gt;
&lt;li&gt;Click the gear icon at the right end of the Network toolbar.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Persist Logs&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Web Console has its own Persist Logs setting too. The limits are the same as Chrome's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I use when the flat list gets in the way
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://netraptor.dev" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, a Chrome extension with its own DevTools panel, and this was one of the gaps we wanted to close. When you open the panel it pulls in what DevTools already logged for the page. After that, each reload, navigation or SPA route change (&lt;code&gt;pushState&lt;/code&gt;, &lt;code&gt;replaceState&lt;/code&gt;, back/forward) starts a group labelled "Reloaded" or "Navigated to" with the path, time and request count. Earlier loads fold away rather than being deleted. If you turn on "Keep history for this site", it saves that site's fetch/XHR calls to IndexedDB, so they're still there after you close DevTools.&lt;/p&gt;

&lt;p&gt;The full guide, with steps and FAQ, is here: &lt;a href="https://netraptor.dev/guides/preserve-network-log-chrome" rel="noopener noreferrer"&gt;Preserve log in Chrome: keep network requests after a page reload&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>chrome</category>
      <category>devtools</category>
      <category>debugging</category>
    </item>
    <item>
      <title>What is a HAR file, and how to get one from Chrome</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:04:44 +0000</pubDate>
      <link>https://dev.to/branch42-labs/what-is-a-har-file-and-how-to-get-one-from-chrome-1olo</link>
      <guid>https://dev.to/branch42-labs/what-is-a-har-file-and-how-to-get-one-from-chrome-1olo</guid>
      <description>&lt;p&gt;A HAR file (HTTP Archive) is a JSON file that records every network request a browser made while a page was open, including URLs, headers, request bodies, responses and timings. Support teams ask for one because it shows exactly what your browser sent and received when something broke. You can generate a HAR file in Chrome from the Network panel in DevTools in under a minute.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://netraptor.dev?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=launch_oct26_organic&amp;amp;utm_content=what-is-a-har-file" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, which this guide mentions as one option. The steps work with plain Chrome and free tools.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open the Network panel.&lt;/strong&gt; Go to the page with the problem and open DevTools with F12, &lt;code&gt;Ctrl+Shift+I&lt;/code&gt; on Windows and Linux, or &lt;code&gt;Cmd+Option+I&lt;/code&gt; on macOS. Click the Network tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on Preserve log.&lt;/strong&gt; Check Preserve log so requests are kept across page loads and redirects. Check Disable cache too if the problem only shows up on a fresh load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce the problem.&lt;/strong&gt; Reload the page and repeat the exact steps that cause the issue. DevTools only records while it is open, so keep it open the whole time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export the HAR file.&lt;/strong&gt; Click the download icon, Export HAR (sanitized)..., in the action bar at the top of the Network panel, and choose where to save the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the file before sharing.&lt;/strong&gt; Open the &lt;code&gt;.har&lt;/code&gt; file in a text editor and search for tokens, emails or other secrets before you send it to anyone.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What is a HAR file?
&lt;/h2&gt;

&lt;p&gt;HAR stands for HTTP Archive. A HAR file is plain JSON with a &lt;code&gt;.har&lt;/code&gt; extension that describes a browser's network activity: one entry per request, grouped under the pages that triggered them. Chrome, Edge, Firefox and Safari can all export it, and most debugging tools can read it.&lt;/p&gt;

&lt;p&gt;The format was drafted as HAR 1.2 for the W3C Web Performance Working Group. That draft was never published as a standard and is now marked as abandoned, but browsers still write files that follow it. A trimmed HAR file looks like this:&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;"log"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WebInspector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"537.36"&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;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"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;"page_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://app.example.com/"&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;span class="nl"&gt;"entries"&lt;/span&gt;&lt;span class="p"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"pageref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"startedDateTime"&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-10-16T09:12:03.120Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;184.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://app.example.com/api/orders"&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;span class="nl"&gt;"response"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"statusText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Internal Server Error"&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;span class="nl"&gt;"timings"&lt;/span&gt;&lt;span class="p"&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;span class="nl"&gt;"blocked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"send"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"wait"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;176.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"receive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.5&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;span class="p"&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;span class="p"&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;h2&gt;
  
  
  What's inside a HAR file
&lt;/h2&gt;

&lt;p&gt;Each entry in &lt;code&gt;log.entries&lt;/code&gt; captures one request and its response. The fields you will look at most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;request&lt;/code&gt;: method, full URL, query string, headers, cookies and the request body (&lt;code&gt;postData&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;response&lt;/code&gt;: status code, headers, cookies, MIME type and usually the response body in &lt;code&gt;content.text&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timings&lt;/code&gt;: how long the request spent blocked, on DNS, connecting, sending, waiting for the server (&lt;code&gt;wait&lt;/code&gt;) and downloading (&lt;code&gt;receive&lt;/code&gt;), in milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startedDateTime&lt;/code&gt; and &lt;code&gt;time&lt;/code&gt;: when the request began and how long it took in total.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pages&lt;/code&gt;: the page loads the requests belong to, so you can tell which navigation triggered what.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because it holds full headers and bodies, a HAR file is often the fastest way for someone else to see a failed API call, a slow request or a bad redirect without access to your account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Export HAR (sanitized) vs. with sensitive data
&lt;/h2&gt;

&lt;p&gt;Since Chrome 130, the default HAR export is sanitized: it leaves out the &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;Set-Cookie&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt; headers. This change was made to cut down on accidental leaks of login sessions when people attach HAR files to support tickets.&lt;/p&gt;

&lt;p&gt;If you need those headers, for example to debug your own auth flow, open DevTools Settings &amp;gt; Preferences &amp;gt; Network and turn on Allow to generate HAR with sensitive data. The Export HAR button then offers both a sanitized and a sensitive-data option. See the &lt;a href="https://developer.chrome.com/docs/devtools/network/reference" rel="noopener noreferrer"&gt;Network features reference&lt;/a&gt; for the current labels.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sanitized only removes those three headers. Tokens in URLs, request bodies, custom headers like &lt;code&gt;X-Api-Key&lt;/code&gt; and response bodies are still in the file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How to get a HAR file in Edge, Firefox and Safari
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Edge uses the same DevTools as Chrome. Follow the steps above.&lt;/li&gt;
&lt;li&gt;Firefox: open the Network Monitor (&lt;code&gt;Ctrl+Shift+E&lt;/code&gt; or &lt;code&gt;Cmd+Option+E&lt;/code&gt;), reproduce the issue, then right-click any request and choose Save All As HAR.&lt;/li&gt;
&lt;li&gt;Safari: enable the Develop menu in Safari settings, open Web Inspector, go to the Network tab, reproduce the issue and click Export.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever browser you use, start recording before you reproduce the problem. A HAR file only contains requests made while the network tools were open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it safe to share a HAR file?
&lt;/h2&gt;

&lt;p&gt;Treat a HAR file like a password. A file exported with sensitive data contains session cookies and bearer tokens, and anyone who has it can often act as you until those expire. Even a sanitized file can include personal data from API responses, IDs in URLs and API keys in custom headers.&lt;/p&gt;

&lt;p&gt;Before you share one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the sanitized export unless the recipient specifically needs auth headers.&lt;/li&gt;
&lt;li&gt;Record in a fresh tab with only the steps needed to reproduce the issue, so unrelated traffic stays out of the file.&lt;/li&gt;
&lt;li&gt;Search the file for &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt; and your email address.&lt;/li&gt;
&lt;li&gt;Log out after sending a file that included cookies. That invalidates the session on most sites.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google's &lt;a href="https://toolbox.googleapps.com/apps/har_analyzer/" rel="noopener noreferrer"&gt;HAR Analyzer&lt;/a&gt; also has a redaction button that downloads a copy with sensitive values removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing this with Netraptor
&lt;/h2&gt;

&lt;p&gt;Chrome's Network panel records everything, but only while DevTools is open, and you have to remember to turn on Preserve log first. If you capture the same app often, &lt;a href="https://netraptor.dev/" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt; can record it in the background instead.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the Netraptor toolbar popup, turn on Record this site. It records the fetch and XHR API traffic for that domain and its subdomains across page loads, with or without DevTools open.&lt;/li&gt;
&lt;li&gt;Open the Netraptor panel in DevTools to browse the recorded requests in a Chrome-style Requests table, or group them by endpoint in API Map.&lt;/li&gt;
&lt;li&gt;On the Export tab, export a HAR file. Strip auth headers and cookies is on by default, so the file is safer to share.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HAR export is part of the Free plan, along with Postman and curl exports. Everything stays in your browser and no account is needed. Netraptor records API calls only, so if you need images, scripts and the full page-load waterfall in the file, use Chrome's own export.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with a HAR file next
&lt;/h2&gt;

&lt;p&gt;Once you have the file, you can load it back into Chrome DevTools, open it in an online viewer or query it with &lt;code&gt;jq&lt;/code&gt;. See &lt;a href="https://netraptor.dev/guides/how-to-open-a-har-file" rel="noopener noreferrer"&gt;how to open and analyze a HAR file&lt;/a&gt;. If you want to replay the requests you captured, turn them into a &lt;a href="https://netraptor.dev/guides/create-postman-collection-from-browser" rel="noopener noreferrer"&gt;Postman collection&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does HAR stand for?
&lt;/h3&gt;

&lt;p&gt;HAR stands for HTTP Archive. It is a JSON-based format for logging a browser's network requests and responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my HAR file empty?
&lt;/h3&gt;

&lt;p&gt;DevTools only records network traffic while it is open. Open the Network panel first, check Preserve log, then reload the page and reproduce the problem before exporting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I open a HAR file in Notepad?
&lt;/h3&gt;

&lt;p&gt;Yes. A HAR file is plain JSON text, so any text editor can open it. For a readable request list, import it into the Chrome DevTools Network panel or a HAR viewer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a HAR file contain my password?
&lt;/h3&gt;

&lt;p&gt;It can. If you log in while recording, the login request body may include your password in plain text. Record after logging in, or check the file before sharing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are cookies missing from my HAR file?
&lt;/h3&gt;

&lt;p&gt;Chrome exports sanitized HAR files by default and strips the Cookie, Set-Cookie and Authorization headers. Turn on Allow to generate HAR with sensitive data in DevTools settings if you need them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://netraptor.dev/guides/what-is-a-har-file?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=launch_oct26_organic&amp;amp;utm_content=what-is-a-har-file" rel="noopener noreferrer"&gt;netraptor.dev/guides/what-is-a-har-file&lt;/a&gt;. More guides: &lt;a href="https://netraptor.dev/guides?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=launch_oct26_organic&amp;amp;utm_content=what-is-a-har-file" rel="noopener noreferrer"&gt;netraptor.dev/guides&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devtools</category>
      <category>chrome</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What is an OpenAPI spec, and how to generate one from your web app's traffic</title>
      <dc:creator>Parth V Square </dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:04:12 +0000</pubDate>
      <link>https://dev.to/branch42-labs/what-is-an-openapi-spec-and-how-to-generate-one-from-your-web-apps-traffic-24oh</link>
      <guid>https://dev.to/branch42-labs/what-is-an-openapi-spec-and-how-to-generate-one-from-your-web-apps-traffic-24oh</guid>
      <description>&lt;p&gt;An OpenAPI spec is a JSON or YAML document that describes an HTTP API in a standard, language-agnostic format, listing its paths, methods, parameters, request bodies and responses. If your API has no spec yet, you can generate a first draft from real traffic by recording the requests your web app makes in Chrome, exporting them as a HAR file, and converting that file with an open-source tool such as mitmproxy2swagger. You then review the draft by hand, because traffic only shows what was actually called.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://netraptor.dev?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=launch_oct26_organic&amp;amp;utm_content=generate-openapi-spec-from-traffic" rel="noopener noreferrer"&gt;Netraptor&lt;/a&gt;, which this guide mentions as one option. The steps work with plain Chrome and free tools.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record the app's API calls in Chrome.&lt;/strong&gt; Open DevTools (F12, or Cmd+Option+I on a Mac), go to the Network panel, check Preserve log, click the Fetch/XHR filter and reload the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use every feature you want in the spec.&lt;/strong&gt; Sign in with a test account and walk through each screen. Create, edit and delete test records so every endpoint and HTTP method shows up at least once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export a HAR file.&lt;/strong&gt; Click Export HAR (sanitized) in the Network panel toolbar and save the file as &lt;code&gt;app.har&lt;/code&gt;. The sanitized export leaves out &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;Set-Cookie&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt; headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a first pass with mitmproxy2swagger.&lt;/strong&gt; Install it with &lt;code&gt;pip install mitmproxy2swagger&lt;/code&gt;, then run &lt;code&gt;mitmproxy2swagger -i app.har -o openapi.yaml -p https://api.example.com&lt;/code&gt;. The output lists every discovered path under &lt;code&gt;x-path-templates&lt;/code&gt;, each prefixed with &lt;code&gt;ignore:&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the paths and run it again.&lt;/strong&gt; Remove the &lt;code&gt;ignore:&lt;/code&gt; prefix from the paths you want, fix templates such as &lt;code&gt;/users/{id}&lt;/code&gt;, and run the same command again. The second pass writes the operations into &lt;code&gt;paths&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate and edit the result.&lt;/strong&gt; Paste the file into &lt;a href="https://editor.swagger.io/" rel="noopener noreferrer"&gt;Swagger Editor&lt;/a&gt; or run &lt;code&gt;openapi-generator-cli validate -i openapi.yaml&lt;/code&gt;. Then add descriptions, required fields, error responses and auth by hand.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What is OpenAPI?
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://spec.openapis.org/oas/latest.html" rel="noopener noreferrer"&gt;OpenAPI Specification&lt;/a&gt; (OAS) defines a standard, programming-language-agnostic way to describe HTTP APIs. A tool or a person can read an OpenAPI document and learn which endpoints exist, what each one accepts and what it returns, without reading the server code.&lt;/p&gt;

&lt;p&gt;OpenAPI started as the Swagger Specification. SmartBear donated it to the OpenAPI Initiative under the Linux Foundation in late 2015, and it was renamed in early 2016. Today "OpenAPI" is the name of the format, and "Swagger" usually refers to SmartBear's tools such as Swagger UI and Swagger Editor.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://spec.openapis.org/oas/" rel="noopener noreferrer"&gt;published versions&lt;/a&gt; are 2.0, the 3.0.x line, the 3.1.x line and the 3.2.x line, with 3.2.1 the newest. Version 3.1 aligned its Schema Object with JSON Schema draft 2020-12, and most current tools read 3.0 and 3.1. If a tool you rely on rejects a newer version, check its docs before choosing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an OpenAPI spec look like? (JSON and YAML)
&lt;/h2&gt;

&lt;p&gt;An OpenAPI document is one JSON or YAML file, conventionally named &lt;code&gt;openapi.json&lt;/code&gt; or &lt;code&gt;openapi.yaml&lt;/code&gt;. It must have an &lt;code&gt;openapi&lt;/code&gt; version field and an &lt;code&gt;info&lt;/code&gt; object, plus at least one of &lt;code&gt;paths&lt;/code&gt;, &lt;code&gt;components&lt;/code&gt; or &lt;code&gt;webhooks&lt;/code&gt;. Path parameters are written in curly braces, and every placeholder must match a parameter definition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;openapi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3.1.0&lt;/span&gt;
&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Example app API&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0.0&lt;/span&gt;
&lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;/users/{id}&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Get a user&lt;/span&gt;
      &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
          &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;path&lt;/span&gt;
          &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;responses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The user&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;application/json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
                &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON form holds the same structure with braces and quotes. YAML is easier to edit by hand, JSON is easier to generate and parse, and most tools accept either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a spec generated from traffic will miss
&lt;/h2&gt;

&lt;p&gt;Recorded traffic shows real requests and responses, which makes it a good starting point. It also has gaps you need to fill before anyone treats the file as documentation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Endpoints you never triggered are missing. Admin screens, error paths and rarely used features only appear if you exercise them.&lt;/li&gt;
&lt;li&gt;Schemas are inferred from examples. A field that was &lt;code&gt;null&lt;/code&gt; in every response, or an optional field you never sent, will be typed loosely or left out.&lt;/li&gt;
&lt;li&gt;Path parameters need checking. Generators guess which segments are IDs, so &lt;code&gt;/users/42&lt;/code&gt; and &lt;code&gt;/users/me&lt;/code&gt; can end up merged or split the wrong way.&lt;/li&gt;
&lt;li&gt;Auth is usually missing. Add a &lt;code&gt;securitySchemes&lt;/code&gt; entry under &lt;code&gt;components&lt;/code&gt; (for example a bearer token) and a &lt;code&gt;security&lt;/code&gt; requirement yourself.&lt;/li&gt;
&lt;li&gt;Enums, formats, limits and descriptions come from you, not the traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the generated file as a draft. One careful review pass usually turns it into something your team can rely on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it safe to generate a spec from production traffic?
&lt;/h2&gt;

&lt;p&gt;Be careful with what goes into the HAR file and the spec. Chrome's sanitized HAR export removes &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;Set-Cookie&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt; headers, but request and response bodies stay in the file, and so do tokens passed in URLs or custom headers.&lt;/p&gt;

&lt;p&gt;mitmproxy2swagger adds example data only when you pass &lt;code&gt;--examples&lt;/code&gt; (and headers with &lt;code&gt;--headers&lt;/code&gt;). Its README warns that this can include sensitive data. If you want examples in the spec, record against a test account with fake data, then read the examples before you commit or share the file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Record traffic only from apps you build, test or are authorised to test, and prefer a staging environment over production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What to do with an OpenAPI spec once you have one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Publish docs. Swagger UI and similar tools render the file as browsable reference pages.&lt;/li&gt;
&lt;li&gt;Generate clients and server stubs. &lt;a href="https://openapi-generator.tech/docs/usage" rel="noopener noreferrer"&gt;OpenAPI Generator&lt;/a&gt; takes &lt;code&gt;-i&lt;/code&gt; (the spec), &lt;code&gt;-g&lt;/code&gt; (a generator such as &lt;code&gt;typescript-fetch&lt;/code&gt;) and &lt;code&gt;-o&lt;/code&gt; (an output folder).&lt;/li&gt;
&lt;li&gt;Test against it. The spec becomes a contract you can check the real API against in CI.&lt;/li&gt;
&lt;li&gt;Import it into Postman. Postman imports OpenAPI 3.0 and 3.1 as a collection. See &lt;a href="https://netraptor.dev/guides/create-postman-collection-from-browser" rel="noopener noreferrer"&gt;Create a Postman collection from a website's requests&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only need the list of endpoints and not a full spec, &lt;a href="https://netraptor.dev/guides/find-api-endpoints-of-a-web-app" rel="noopener noreferrer"&gt;Find every API endpoint your web app calls&lt;/a&gt; covers the DevTools side in more detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Faster: generate the spec with Netraptor
&lt;/h2&gt;

&lt;p&gt;Netraptor is a Chrome extension with its own DevTools panel. It shortens the record, convert and template steps above, and everything stays local in your browser.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Turn on "Record this site" in the toolbar popup. Netraptor records fetch/XHR API traffic for that domain and its subdomains across page loads, even with DevTools closed.&lt;/li&gt;
&lt;li&gt;Optionally run "Crawl this site" to visit same-origin links in your logged-in tab. It skips logout and delete-looking links. Free crawls cover 25 pages, Pro up to 1000.&lt;/li&gt;
&lt;li&gt;Check the API Map tab. Endpoints are grouped and IDs are already parameterized, for example &lt;code&gt;/users/{id}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In the Export tab, export OpenAPI 3.1 as YAML or JSON, with optional response samples. "Strip auth headers and cookies" is on by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenAPI export is part of Pro ($29.99 one-time, no subscription). The Free plan exports HAR, Postman Collection v2.1 and curl, so you can export a HAR from Netraptor and run it through mitmproxy2swagger instead. You still review the generated spec by hand either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is OpenAPI the same as Swagger?
&lt;/h3&gt;

&lt;p&gt;OpenAPI is the specification, and it was called the Swagger Specification until 2016. Swagger now refers to SmartBear's tools, such as Swagger UI and Swagger Editor, that work with OpenAPI files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use OpenAPI 3.0, 3.1 or 3.2?
&lt;/h3&gt;

&lt;p&gt;Use 3.1 unless a tool in your pipeline only supports 3.0. Version 3.1 matches JSON Schema 2020-12, and 3.2 is newer, so check tool support before adopting it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is OpenAPI Generator?
&lt;/h3&gt;

&lt;p&gt;OpenAPI Generator is an open-source CLI that turns an OpenAPI document into client SDKs, server stubs and docs for many languages. It also has a &lt;code&gt;validate&lt;/code&gt; command for checking a spec.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I generate an OpenAPI spec from code instead of traffic?
&lt;/h3&gt;

&lt;p&gt;Yes, if you own the backend. Many frameworks, FastAPI for example, produce a spec from route definitions, and that is usually more complete than one inferred from traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between openapi.json and openapi.yaml?
&lt;/h3&gt;

&lt;p&gt;They hold the same document in two syntaxes. The spec allows both, and most tools accept either.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://netraptor.dev/guides/generate-openapi-spec-from-traffic?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=launch_oct26_organic&amp;amp;utm_content=generate-openapi-spec-from-traffic" rel="noopener noreferrer"&gt;netraptor.dev/guides/generate-openapi-spec-from-traffic&lt;/a&gt;. More guides: &lt;a href="https://netraptor.dev/guides?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=launch_oct26_organic&amp;amp;utm_content=generate-openapi-spec-from-traffic" rel="noopener noreferrer"&gt;netraptor.dev/guides&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openapi</category>
      <category>api</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
