<?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: Anton Kopylov</title>
    <description>The latest articles on DEV Community by Anton Kopylov (@tonic20).</description>
    <link>https://dev.to/tonic20</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012468%2F36c4138d-7ced-4ff7-8aa1-4f1975d892bd.jpg</url>
      <title>DEV Community: Anton Kopylov</title>
      <link>https://dev.to/tonic20</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tonic20"/>
    <language>en</language>
    <item>
      <title>dataforseo-ruby: A Client That Respects the Bill</title>
      <dc:creator>Anton Kopylov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:19:00 +0000</pubDate>
      <link>https://dev.to/tonic20/dataforseo-ruby-a-client-that-respects-the-bill-4cji</link>
      <guid>https://dev.to/tonic20/dataforseo-ruby-a-client-that-respects-the-bill-4cji</guid>
      <description>&lt;p&gt;Two of my apps call &lt;a href="https://dataforseo.com" rel="noopener noreferrer"&gt;DataForSEO&lt;/a&gt;: &lt;a href="https://extensiondash.com" rel="noopener noreferrer"&gt;ExtensionDash&lt;/a&gt; for keyword volumes and Google positions, and the workspace I run my own SEO from. Each had grown its own copy of the HTTP code, and the copies had started to disagree about what an error looks like. So I pulled them out into a gem, &lt;a href="https://github.com/tonic20/dataforseo-ruby" rel="noopener noreferrer"&gt;&lt;strong&gt;dataforseo-ruby&lt;/strong&gt;&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"dataforseo-ruby"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataForSeo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;login: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"DATAFORSEO_LOGIN"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;password: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"DATAFORSEO_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;google_ads_search_volume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;keywords: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"reverse image search"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;language_code: &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;  &lt;span class="c1"&gt;# =&amp;gt; [{"keyword" =&amp;gt; "reverse image search", "search_volume" =&amp;gt; 1500000, ...}]&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cost&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; 0.09&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has no runtime dependencies: &lt;code&gt;net/http&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt; and &lt;code&gt;zlib&lt;/code&gt; from the standard library, Ruby 3.4 or newer. Most of it is about one fact: &lt;strong&gt;every request costs money, including the ones that fail.&lt;/strong&gt; That fact explains most of the decisions below.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 200 is not a success
&lt;/h2&gt;

&lt;p&gt;DataForSEO answers almost everything with HTTP 200 and puts the real outcome in a &lt;code&gt;status_code&lt;/code&gt; inside the body: once for the request, then once for each task in it. A client that only checks the HTTP status will hand you an empty result as if it were an answer.&lt;/p&gt;

&lt;p&gt;So every envelope is checked, top level and per task, and any failure becomes a &lt;code&gt;DataForSeo::Error&lt;/code&gt;: &lt;code&gt;AuthenticationError&lt;/code&gt;, &lt;code&gt;PaymentRequired&lt;/code&gt;, &lt;code&gt;RateLimitError&lt;/code&gt;, &lt;code&gt;TransientError&lt;/code&gt;, or a plain &lt;code&gt;ApiError&lt;/code&gt; carrying the code, message, path and task id. Timeouts, TLS failures and unparseable bodies become &lt;code&gt;ConnectionError&lt;/code&gt;. If you send three tasks and get two back, that raises too. A missing task is not an empty one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save what you paid for
&lt;/h2&gt;

&lt;p&gt;The bulk endpoints take up to a thousand inputs per request, so the client slices your list and sends it one slice at a time. Pass a block and you get each slice's response before the next request goes out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bulk_spam_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;targets: &lt;/span&gt;&lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;slice&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;SpamScore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# already paid for; keep it even if the next slice fails&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each response carries &lt;code&gt;requested&lt;/code&gt;, the inputs sent in &lt;em&gt;that&lt;/em&gt; request. That is how a caller tells "the API left this target out" from "we never got to asking", which are opposite facts and look the same in an empty result.&lt;/p&gt;

&lt;h2&gt;
  
  
  No retries, on purpose
&lt;/h2&gt;

&lt;p&gt;The client does not retry and does not pace. That sounds lazy, but the reason is that a timeout after the API accepted a request can still be charged, and retrying pays again. Whether a second attempt is worth the money depends on the caller, so the error is the caller's to handle.&lt;/p&gt;

&lt;p&gt;The queue endpoints follow the same rule. Google organic tasks are billed when they are accepted, so &lt;code&gt;google_organic_task_post&lt;/code&gt; returns rejected tasks in &lt;code&gt;tasks[]&lt;/code&gt; instead of raising. Raising there would throw away the ids of the tasks you have already paid for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;client.post(path, tasks)&lt;/code&gt; reaches any other Live endpoint, with the same envelope checks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DataForSeo::Testing::FakeTransport&lt;/code&gt; queues bodies, or exceptions, for your own tests and records every call.&lt;/li&gt;
&lt;li&gt;The README lists the surprises I paid to learn. Google Ads Live allows 12 requests a minute. A keyword over 80 characters or 10 words is rejected. One malformed domain voids a whole &lt;code&gt;bulk_spam_score&lt;/code&gt; request, for zero items and zero cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is unofficial, MIT licensed, and &lt;a href="https://rubygems.org/gems/dataforseo-ruby" rel="noopener noreferrer"&gt;on RubyGems&lt;/a&gt;. It covers the endpoints I use. If you need another one typed, open an issue or a pull request on &lt;a href="https://github.com/tonic20/dataforseo-ruby" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>opensource</category>
      <category>seo</category>
      <category>api</category>
    </item>
    <item>
      <title>Decibel Meter: A Sound Level Meter in the Browser</title>
      <dc:creator>Anton Kopylov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:42:31 +0000</pubDate>
      <link>https://dev.to/tonic20/decibel-meter-a-sound-level-meter-in-the-browser-1lnf</link>
      <guid>https://dev.to/tonic20/decibel-meter-a-sound-level-meter-in-the-browser-1lnf</guid>
      <description>&lt;p&gt;A lot of people type "decibel meter" into the Chrome Web Store, and the most installed result is rated three stars. That was the whole brief. &lt;a href="https://decibelmeter.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;Decibel Meter&lt;/strong&gt;&lt;/a&gt; is my answer: press Start, allow the microphone once, and see how loud the room is in dB(A), with min, max, the average since you began, a 60-second chart and the current level on the toolbar badge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6oa81hk31mfe82j1hsi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6oa81hk31mfe82j1hsi.png" alt="Decibel Meter measuring a room at 67 dB(A), with min, average, max and a 60-second chart" width="760" height="1096"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A PRO switch adds what a handheld meter has: Fast and Slow response, flat dB(Z) next to dB(A), calibration against a reference device, a noise alert and a CSV log of the session. The alert is what turns it into a classroom noise monitor. It watches the average over a window you pick, from five seconds to five minutes, so one slammed door does not set it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that are easy to get wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check the level now and then, and you miss sound.&lt;/strong&gt; The obvious way to build a meter is to read the microphone level each time the screen redraws. Redraws do not come at a steady pace, so some sound gets counted twice and some not at all. And when the window is in the background, the browser stops redrawing it, which is exactly when a noise monitor is supposed to be working. Decibel Meter instead measures every bit of sound as it comes in from the microphone, in the part of the browser that handles audio and keeps running in the background. Every 50 ms it hands the page a total, and the page only draws it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An average of decibels is not an average.&lt;/strong&gt; Ten minutes at 50 dB and ten at 80 dB do not average to 65. Decibels are a log scale, so the average has to be taken over energy and converted back, and the honest answer is about 77: the loud half dominates, as it does for your ears. That is Leq, and it is what the meter shows as the average.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your microphone is not flat.&lt;/strong&gt; It hears bass and treble differently from people, which is what A-weighting corrects for. The meter applies the standard A-weighting curve, and it stays within 0.3 dB of it up to 2 kHz. What no filter fixes is that a laptop microphone has no absolute scale. The meter adds a fixed offset that puts a quiet room at 30–40 dB, and PRO lets you calibrate against a real meter if you have one. It is a good guide to how loud a room is. It is not a certified instrument, and it does not pretend to be one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it opens a window
&lt;/h2&gt;

&lt;p&gt;The toolbar popup is where an extension's UI normally lives, and it does not work here: a popup cannot show the microphone permission prompt, and it closes the moment it loses focus, which would stop the measurement. So the icon opens the meter in a small window of its own and focuses it on the next click instead of opening a second one. It keeps measuring while you work in other tabs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Decibel Meter is free in the &lt;a href="https://chromewebstore.google.com/detail/decibel-meter/cacdlkmppiphnmbcfadgohekneddaege" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;, with no account. If your reading disagrees with a real meter, or the alert fires when it should not, I would like to hear about it at &lt;a href="mailto:anton@kopylov.net"&gt;anton@kopylov.net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>showdev</category>
      <category>chrome</category>
    </item>
    <item>
      <title>ExtensionDash: tracking a browser extension from your agent</title>
      <dc:creator>Anton Kopylov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 17:03:41 +0000</pubDate>
      <link>https://dev.to/tonic20/extensiondash-tracking-a-browser-extension-from-your-agent-32ol</link>
      <guid>https://dev.to/tonic20/extensiondash-tracking-a-browser-extension-from-your-agent-32ol</guid>
      <description>&lt;p&gt;After shipping two browser extensions this year — &lt;a href="https://reverseimage.app" rel="noopener noreferrer"&gt;Reverse Image Search Anywhere&lt;/a&gt; and &lt;a href="https://stackpeek.app" rel="noopener noreferrer"&gt;Stackpeek&lt;/a&gt; — I still could not answer the simplest question about a listing: &lt;em&gt;is it working?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both stores tell you how many people installed the thing. Neither tells you the number that decides whether anyone installs it at all: where you appear when someone searches the words they would actually type. The Chrome Web Store developer dashboard has no notion of search position. Firefox's is no better. So the honest answer to "did last week's title change help?" was to open an incognito window, type the keyword, and count down the results with my finger. In three languages.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://extensiondash.com" rel="noopener noreferrer"&gt;&lt;strong&gt;ExtensionDash&lt;/strong&gt;&lt;/a&gt;. It scrapes both stores nightly and records installs, ratings, category rank, keyword positions, competitors and listing copy — and keeps the history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that mattered
&lt;/h2&gt;

&lt;p&gt;Not the scraper. It was making the agent a first-class reader rather than an export button.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /mcp&lt;/code&gt; speaks the &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; over stateless HTTP, authenticated with a per-user token. Fifteen tools; eleven read, and the four that write cannot delete recorded history. Everything the dashboard renders, an agent can read.&lt;/p&gt;

&lt;p&gt;That part mostly just works. What took actual thought is that &lt;strong&gt;an agent needs absence to be legible&lt;/strong&gt;, and a web page does not.&lt;/p&gt;

&lt;p&gt;A human seeing a blank cell reads it from context: still loading, new row, or genuinely nothing. An agent gets a JSON document with a &lt;code&gt;null&lt;/code&gt; in it, and a &lt;code&gt;null&lt;/code&gt; is a wonderfully confident thing to summarize as a zero. "Your extension does not rank for this keyword" and "nobody has looked yet" are opposite facts, and a tool that returns the same shape for both will get one narrated as the other to a user who believes it.&lt;/p&gt;

&lt;p&gt;So three states stay distinct: never scanned, scanned and found, scanned but absent. &lt;code&gt;add_keyword&lt;/code&gt; reports whether a fetch was actually enqueued and names the tools to poll. &lt;code&gt;list_scrape_runs&lt;/code&gt; surfaces a &lt;em&gt;failed&lt;/em&gt; fetch as a failure rather than as a position that silently never arrives.&lt;/p&gt;

&lt;p&gt;Same instinct behind the field I am most attached to: every position travels with the depth it was scanned to. #1 of 9 and #1 of 50 are different results. Reporting "you rank #12" while omitting that the scan stopped at 50 is how a tool lies by arithmetic rather than by mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  History you did not pay for
&lt;/h2&gt;

&lt;p&gt;Every nightly keyword search already scans fifty results, so all fifty are recorded. &lt;code&gt;add_competitor&lt;/code&gt; promotes one — which earns it a listing fetch, and therefore a description, installs and a rating — but the ranks are already there. A competitor you add today arrives with rank history going back to the day you started tracking the keyword. You get to ask "when did they pass me?" about someone you had not thought about until this morning.&lt;/p&gt;

&lt;p&gt;Stack is unremarkable on purpose: Rails 8, Postgres, Solid Queue with browser work on its own single-threaded queue, Nokogiri and Ferrum for Chrome, the public AMO API for Firefox. Kamal from a laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The beta
&lt;/h2&gt;

&lt;p&gt;Open, free, no card, at &lt;a href="https://extensiondash.com" rel="noopener noreferrer"&gt;extensiondash.com&lt;/a&gt;. Limits are three extensions per account and fifteen keywords per extension, because a keyword is a nightly browser session in perpetuity — not because there is a paid tier behind them.&lt;/p&gt;

&lt;p&gt;What I would most like people to try is the part I cannot evaluate alone: &lt;strong&gt;connect it to your agent and ask it something real.&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 &lt;span class="nt"&gt;--transport&lt;/span&gt; http extensiondash https://extensiondash.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;your token&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token and a ready-to-paste command are on the Help page. Then ask your agent why an extension slipped last week, or what a competitor changed — and tell me where it stalls, what it guesses at, and what it reaches for that is not there. I designed those fifteen tools by reasoning about what an agent would need. Reasoning about it is not the same as watching it happen.&lt;/p&gt;

&lt;p&gt;Anything else you find, I want too: &lt;a href="mailto:anton@kopylov.net"&gt;anton@kopylov.net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>rails</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Reverse image search breaks on Instagram. Here's the fallback ladder that fixes it.</title>
      <dc:creator>Anton Kopylov</dc:creator>
      <pubDate>Tue, 28 Jul 2026 18:10:04 +0000</pubDate>
      <link>https://dev.to/tonic20/reverse-image-search-breaks-on-instagram-heres-the-fallback-ladder-that-fixes-it-me</link>
      <guid>https://dev.to/tonic20/reverse-image-search-breaks-on-instagram-heres-the-fallback-ladder-that-fixes-it-me</guid>
      <description>&lt;p&gt;Every one-click reverse image search extension works the same way, and it is not the way you'd guess. It doesn't upload anything. It builds a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENGINES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`https://lens.google.com/uploadbyurl?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`https://www.bing.com/images/searchbyimage?cbir=sbi&amp;amp;imgurl=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;yandex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`https://yandex.com/images/search?rpt=imageview&amp;amp;url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tineye&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`https://tineye.com/search?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;Right-click an image, read &lt;code&gt;info.srcUrl&lt;/code&gt;, open four tabs. The engine fetches the image itself, server-side. It's twenty lines and it's genuinely elegant — the extension never touches image bytes, never needs a backend, never sees your data.&lt;/p&gt;

&lt;p&gt;It also fails on a large fraction of the web, and it fails &lt;em&gt;silently&lt;/em&gt;: you get a results page that says "no matches," which is indistinguishable from a real no-match.&lt;/p&gt;

&lt;p&gt;I hit this building &lt;a href="https://reverseimage.app" rel="noopener noreferrer"&gt;Reverse Image Search Anywhere&lt;/a&gt;, and digging out took four distinct fallback tiers. This is what's under each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem zero: you don't even get a URL
&lt;/h2&gt;

&lt;p&gt;Before the redirect can fail, something more basic breaks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chrome.contextMenus&lt;/code&gt; only populates &lt;code&gt;info.srcUrl&lt;/code&gt; when the right-click actually landed on an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element. Instagram, Pinterest, and Facebook all lay a transparent element over each photo — an interaction shield for their own gesture handling. Your right-click hits the shield. Chrome reports a &lt;code&gt;page&lt;/code&gt; context with no image at all, and your extension's image menu item doesn't even appear.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking the browser and look yourself. A content script listens for &lt;code&gt;contextmenu&lt;/code&gt; in the &lt;strong&gt;capture&lt;/strong&gt; phase (so the page can't &lt;code&gt;stopPropagation()&lt;/code&gt; you out of it) and walks the element stack under the cursor:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contextmenu&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;e&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;lastRightClickSrc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pickImageUrl&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;elementsFromPoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;));&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;// ← capture&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pickImageUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Element&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="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;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;elements&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="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IMG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLImageElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="c1"&gt;// currentSrc, not src — with srcset, `src` is the fallback the browser&lt;/span&gt;
      &lt;span class="c1"&gt;// may never have loaded.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentSrc&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&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;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;src&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="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;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;elements&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="nf"&gt;backgroundImageUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// computed style, first url(...)&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;url&lt;/span&gt;&lt;span class="p"&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;document.elementsFromPoint()&lt;/code&gt; returns the whole stack, topmost first, so the shield is element 0 and the real &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; is usually element 1 or 2. Two details earn their keep: &lt;code&gt;currentSrc&lt;/code&gt; over &lt;code&gt;src&lt;/code&gt; (with &lt;code&gt;srcset&lt;/code&gt;, &lt;code&gt;src&lt;/code&gt; is a fallback the browser may never have fetched), and the CSS &lt;code&gt;background-image&lt;/code&gt; pass, which catches the surprising number of sites that render photos as div backgrounds.&lt;/p&gt;

&lt;p&gt;Now the background worker can ask the content script "what was under the cursor?" and get an answer where Chrome had none.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL you got may still be useless
&lt;/h2&gt;

&lt;p&gt;Having a URL isn't the same as having one an engine can fetch. It's useless when it's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;blob:&lt;/code&gt; or &lt;code&gt;data:&lt;/code&gt;&lt;/strong&gt; — page-scoped. &lt;code&gt;blob:https://example.com/abc-123&lt;/code&gt; is meaningless outside that document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hotlink-protected&lt;/strong&gt; — the CDN checks &lt;code&gt;Referer&lt;/code&gt; and serves a 403 to Google's fetcher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cookie-gated&lt;/strong&gt; — a signed URL on an authenticated CDN. Google isn't logged in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;drawn on a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;&lt;/strong&gt; — there is no URL. There was never a URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So for those cases you need the actual bytes. Which is where it gets interesting, because &lt;em&gt;no single mechanism can grab them all&lt;/em&gt;, and the reasons are unrelated to each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four tiers, each covering the one above it
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Reaches&lt;/th&gt;
&lt;th&gt;Blind to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;It's already a &lt;code&gt;data:&lt;/code&gt; URL&lt;/td&gt;
&lt;td&gt;inline images&lt;/td&gt;
&lt;td&gt;everything else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt; in the background worker&lt;/td&gt;
&lt;td&gt;most CDNs, hotlink-protected&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;blob:&lt;/code&gt;, cookie-gated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt; in the content script&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;blob:&lt;/code&gt;, cookie-gated, private&lt;/td&gt;
&lt;td&gt;cross-origin without CORS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Rasterize the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; to a canvas&lt;/td&gt;
&lt;td&gt;same-origin, CORS-enabled, canvas&lt;/td&gt;
&lt;td&gt;tainted canvases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — the background worker.&lt;/strong&gt; This is the one people miss. A Manifest V3 service worker holding &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; host permissions makes fetches that are &lt;em&gt;not subject to page CORS&lt;/em&gt;. The extension's origin is privileged; no preflight, no opaque response. That single property gets you the bytes of most CDN images that the redirect method can't hand to an engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;grabImageBlobFromBackground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Blob&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Page-scoped schemes are unreachable from here, by construction.&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;srcUrl&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="s1"&gt;blob:&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="nx"&gt;srcUrl&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="s1"&gt;data:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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;res&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;srcUrl&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;omit&lt;/span&gt;&lt;span class="dl"&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;res&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;blob&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&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;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&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="s1"&gt;image/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blob&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="k"&gt;return&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;credentials: 'omit'&lt;/code&gt;. The worker has no useful session for a third-party site anyway, and sending what it does have to arbitrary origins is not a thing you want to do by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — inside the page.&lt;/strong&gt; Tier 1 returns &lt;code&gt;null&lt;/code&gt; for exactly two situations, and the content script is the answer to both. A &lt;code&gt;blob:&lt;/code&gt; URL only resolves in the document that created it. And an in-page &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;credentials: 'include'&lt;/code&gt; carries the site's session cookies &lt;em&gt;and&lt;/em&gt; a same-origin &lt;code&gt;Referer&lt;/code&gt;, so it reaches cookie-gated and private images — the ones on an authenticated CDN that no external fetcher can touch.&lt;/p&gt;

&lt;p&gt;Same request, different execution context, completely different reachability. That's the whole trick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — the canvas.&lt;/strong&gt; If even an in-page fetch fails (cross-origin, no CORS headers), the image is still &lt;em&gt;rendered&lt;/em&gt; on screen. Those pixels exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;elementToDataUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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="nx"&gt;images&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&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;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentSrc&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;srcUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;srcUrl&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;img&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;complete&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image element not found&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;canvas&lt;/span&gt; &lt;span class="o"&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Throws SecurityError if the canvas is tainted (cross-origin, no CORS).&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&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;And here the ladder honestly ends. Drawing a cross-origin image without CORS headers &lt;em&gt;taints&lt;/em&gt; the canvas, and &lt;code&gt;toDataURL()&lt;/code&gt; throws &lt;code&gt;SecurityError&lt;/code&gt;. That's not a bug to route around — it's the same-origin policy doing its job. The extension shows a toast and stops. A tool that claims to work everywhere is lying about this tier.&lt;/p&gt;

&lt;p&gt;The chain, cheapest first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcUrl&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="s1"&gt;data:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;searchImageByUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ids&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;blob&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;grabImageBlobFromBackground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcUrl&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;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;searchImageBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// blob: / cookie-gated — only the page can reach it&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendToTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MSG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GRAB_IMAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;engineIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bytes in hand. The engines still want a URL.
&lt;/h2&gt;

&lt;p&gt;Google Lens has no public "here are some bytes" endpoint. So the bytes need to briefly &lt;em&gt;become&lt;/em&gt; a URL: upload to private S3, hand each engine a short-lived presigned GET.&lt;/p&gt;

&lt;p&gt;The presign is minted by a small Rails endpoint, and it contains the single most useful thing I learned on this project:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A presigned PUT cannot cap upload size.&lt;/strong&gt; S3 does not enforce a signed &lt;code&gt;Content-Length&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I verified this empirically after assuming otherwise. If you hand out a presigned PUT, whoever holds that URL can write an object of any size, up to S3's limits, and you pay for it. A presigned &lt;strong&gt;POST&lt;/strong&gt; with &lt;code&gt;content_length_range&lt;/code&gt; is different — the constraint is in the signed policy document, so &lt;strong&gt;S3 itself&lt;/strong&gt; rejects an oversized body with &lt;code&gt;EntityTooLarge&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;S3Presigner&lt;/span&gt;
  &lt;span class="no"&gt;PUT_TTL&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;              &lt;span class="c1"&gt;# 5 min to complete the upload&lt;/span&gt;
  &lt;span class="no"&gt;GET_TTL&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;              &lt;span class="c1"&gt;# 15 min for the engines to fetch&lt;/span&gt;
  &lt;span class="no"&gt;MAX_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="c1"&gt;# enforced by S3, not by us&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"u/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;SecureRandom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Aws&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;S3&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PresignedPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;key: &lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;content_type: &lt;/span&gt;&lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;content_length_range: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="no"&gt;MAX_BYTES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;signature_expiration: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;PUT_TTL&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;upload: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="n"&gt;post&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="ss"&gt;fields: &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fields&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="ss"&gt;get_url: &lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="no"&gt;GET_TTL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;max_bytes: &lt;/span&gt;&lt;span class="no"&gt;MAX_BYTES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three more things that cost me time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field order in the multipart body is load-bearing.&lt;/strong&gt; The &lt;code&gt;file&lt;/code&gt; field must come &lt;em&gt;last&lt;/em&gt;, after every signed policy field. S3 stops parsing at &lt;code&gt;file&lt;/code&gt; — anything after it is ignored, and you get a signature error that says nothing about ordering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&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;FormData&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ← must be last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The upload has to run in the worker too.&lt;/strong&gt; A content script POSTing to S3 hits page CORS. Same reasoning as tier 1 — the privileged context is the one that can talk to arbitrary origins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate-limit the presign endpoint.&lt;/strong&gt; No bytes pass through the server, so it's cheap and easy to forget — but every success hands out an S3 write grant. Rails 8 ships this built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;rate_limit&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;within: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;with: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"rate limited"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :too_many_requests&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bucket has Block Public Access on and never serves anything publicly. The GET URL is unguessable and expires in 15 minutes; a one-day lifecycle rule sweeps objects regardless of what the app does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback I deliberately didn't build
&lt;/h2&gt;

&lt;p&gt;When the whole ladder fails, there's an obvious-looking last resort: give up on the bytes and just open the raw &lt;code&gt;srcUrl&lt;/code&gt; in the engines. Something is better than nothing.&lt;/p&gt;

&lt;p&gt;It isn't. Consider the case that lands there most often — a cookie-gated image on an authenticated CDN, say a generated image in a ChatGPT conversation. That URL is signed and tied to &lt;em&gt;your&lt;/em&gt; session. Handing it to four search engines doesn't work, because they can't fetch it either. What it does do is leak an authenticated link to four third parties.&lt;/p&gt;

&lt;p&gt;So the extension doesn't. This path is reached only when the user explicitly chose "search by upload," or on an overlay site where they implicitly chose not to hand over the image's URL. Quietly falling back to the exact behavior they opted out of would be a bad trade dressed up as a graceful degradation.&lt;/p&gt;

&lt;p&gt;Failing loudly with a toast is the right answer. Not every fallback is worth having.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that matters most
&lt;/h2&gt;

&lt;p&gt;All of the above — the four tiers, the S3 bucket, the presign endpoint, the rate limiter — is the &lt;strong&gt;slow path&lt;/strong&gt;. It runs only when the fast path can't.&lt;/p&gt;

&lt;p&gt;On a normal site with a plain, fetchable image URL, the extension builds four engine URLs and opens four tabs. No bytes are read, no server is contacted, nothing is uploaded. Same twenty elegant lines it started with.&lt;/p&gt;

&lt;p&gt;That ordering wasn't an optimization, it was the design constraint. The expensive path is also the privacy-sensitive one, and building it as an explicit fallback rather than the default means the common case stays fully client-side — and I can say exactly which right-click sends an image anywhere, because it's the only branch that can.&lt;/p&gt;

&lt;p&gt;Ladders are worth building. Just make sure you're on the bottom rung as rarely as possible.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The extension is &lt;a href="https://reverseimage.app" rel="noopener noreferrer"&gt;Reverse Image Search Anywhere&lt;/a&gt; — free, in the Chrome Web Store, and installable on Edge, Brave, Opera and other Chromium browsers from there. A Firefox build is in AMO review. Built with &lt;a href="https://wxt.dev/" rel="noopener noreferrer"&gt;WXT&lt;/a&gt; and TypeScript, with a small Rails backend. If you've found a site that defeats all four tiers, I'd like to hear about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>chrome</category>
      <category>aws</category>
    </item>
    <item>
      <title>HTTP/3 on Kamal, and a PR to Basecamp</title>
      <dc:creator>Anton Kopylov</dc:creator>
      <pubDate>Thu, 02 Jul 2026 15:28:15 +0000</pubDate>
      <link>https://dev.to/tonic20/http3-on-kamal-and-a-pr-to-basecamp-33oa</link>
      <guid>https://dev.to/tonic20/http3-on-kamal-and-a-pr-to-basecamp-33oa</guid>
      <description>&lt;p&gt;A few days ago I read &lt;a href="https://savearoundtrip.com/" rel="noopener noreferrer"&gt;a good writeup&lt;/a&gt; on how much &lt;strong&gt;HTTP/3&lt;/strong&gt; can shave off page loads — QUIC drops a round trip on connection setup and survives network changes without renegotiating. I wanted it on my own sites.&lt;/p&gt;

&lt;p&gt;All of them deploy with &lt;a href="https://kamal-deploy.org" rel="noopener noreferrer"&gt;&lt;strong&gt;Kamal&lt;/strong&gt;&lt;/a&gt;, which fronts each app with &lt;code&gt;kamal-proxy&lt;/code&gt;. The good news: kamal-proxy already speaks HTTP/3 — it has an &lt;code&gt;--http3&lt;/code&gt; flag. The bad news: Kamal gave me no way to turn it on.&lt;/p&gt;

&lt;p&gt;Two things were missing, and the second is the subtle one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Kamal never passes &lt;code&gt;--http3&lt;/code&gt; to the proxy.&lt;/li&gt;
&lt;li&gt;The proxy container only publishes TCP &lt;strong&gt;80&lt;/strong&gt; and &lt;strong&gt;443&lt;/strong&gt;. HTTP/3 runs over &lt;strong&gt;QUIC, which is UDP&lt;/strong&gt; — so even with the flag set, the UDP 443 listener would sit there unreachable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Someone had already filed &lt;a href="https://github.com/basecamp/kamal/issues/1842" rel="noopener noreferrer"&gt;a feature request&lt;/a&gt;, so I wasn't alone in wanting this. I forked Kamal and added a &lt;code&gt;proxy.run.http3&lt;/code&gt; option that does both halves of the job: it passes &lt;code&gt;--http3&lt;/code&gt; to &lt;code&gt;kamal-proxy run&lt;/code&gt;, and it publishes the HTTPS port over UDP as well (&lt;code&gt;443:443/udp&lt;/code&gt;). There's a guard, too — you can't enable HTTP/3 when ports aren't published, since there'd be nothing to listen on.&lt;/p&gt;

&lt;p&gt;Turning it on is one line:&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="c1"&gt;# config/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;http3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until the &lt;a href="https://github.com/basecamp/kamal/pull/1894" rel="noopener noreferrer"&gt;PR&lt;/a&gt; lands, the Gemfile points at the fork:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"kamal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;github: &lt;/span&gt;&lt;span class="s2"&gt;"tonic20/kamal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;branch: &lt;/span&gt;&lt;span class="s2"&gt;"proxy-http3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;require: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped it on &lt;a href="https://www.watchbrandindex.com" rel="noopener noreferrer"&gt;watchbrandindex.com&lt;/a&gt; and &lt;a href="https://savearoundtrip.com/?d=watchbrandindex.com" rel="noopener noreferrer"&gt;confirmed the negotiation&lt;/a&gt; — browser and proxy now agree on h3.&lt;/p&gt;

&lt;p&gt;Now to see if DHH merges it upstream. 🤞&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;P.S.&lt;/strong&gt; — While running other sites through that checker, it flagged this one too. This blog isn't on Kamal; it's a static site on S3 + CloudFront, which already serves HTTP/3. But browsers were discovering it the slow way: connect over h2, read the &lt;code&gt;Alt-Svc&lt;/code&gt; header, &lt;em&gt;then&lt;/em&gt; upgrade to h3 on a later connection. The fix here lives in DNS, not the server — an &lt;a href="https://www.rfc-editor.org/rfc/rfc9460" rel="noopener noreferrer"&gt;&lt;code&gt;HTTPS&lt;/code&gt; resource record&lt;/a&gt; (RFC 9460) at the apex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kopylov.net.  HTTPS  1 . alpn="h3,h2"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now browsers learn h3 is available &lt;em&gt;during&lt;/em&gt; the DNS lookup and try QUIC on the very first connection. One more round trip saved, no server involved.&lt;/p&gt;

</description>
      <category>http3</category>
      <category>kamal</category>
      <category>rails</category>
    </item>
  </channel>
</rss>
