<?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: Vladimir Elchinov</title>
    <description>The latest articles on DEV Community by Vladimir Elchinov (@vladimir_elchinov).</description>
    <link>https://dev.to/vladimir_elchinov</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%2F3418110%2Faca869d2-bf72-4840-ba6b-528b8be3a025.png</url>
      <title>DEV Community: Vladimir Elchinov</title>
      <link>https://dev.to/vladimir_elchinov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vladimir_elchinov"/>
    <language>en</language>
    <item>
      <title>Your Bug Reports Do Not Say Which Fields Were Measured and Which Were Typed</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Tue, 22 Sep 2026 12:57:22 +0000</pubDate>
      <link>https://dev.to/session_replay/your-bug-reports-do-not-say-which-fields-were-measured-and-which-were-typed-56n5</link>
      <guid>https://dev.to/session_replay/your-bug-reports-do-not-say-which-fields-were-measured-and-which-were-typed-56n5</guid>
      <description>&lt;p&gt;Two tickets arrive. Both have an Environment field. Both say &lt;code&gt;Chrome 141, macOS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One of them was read off the browser by a script. The other was typed by a person recalling what they think they use. In your tracker they are the same eleven characters, in the same field, in the same font.&lt;/p&gt;

&lt;p&gt;You are going to trust them equally. One of them does not deserve it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typed values fail in a specific direction
&lt;/h2&gt;

&lt;p&gt;If typed fields were merely noisy, this would not matter much: sometimes right, sometimes wrong, average out over enough tickets.&lt;/p&gt;

&lt;p&gt;They are not noisy. They are wrong in a systematic way, and the bias runs exactly opposite to what you need.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;People report the browser they &lt;em&gt;chose&lt;/em&gt;, which on an iPhone is not the browser that rendered the page, because every iOS browser is WebKit underneath.&lt;/li&gt;
&lt;li&gt;People report "latest", which is a statement about their intentions rather than their build. Chrome had three live major versions in one week this month.&lt;/li&gt;
&lt;li&gt;People report the time by looking at the clock that is wrong, which is the same clock that produced the timestamp you are trying to match against your logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each case the person is being careful and truthful, and the answer is still wrong. Worse, the answer is most wrong precisely in the cases where the field would have been decisive. A correctly typed "Chrome 141" tells you nothing you needed; an incorrectly typed one costs you the afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance is part of the value
&lt;/h2&gt;

&lt;p&gt;The fix is not more fields or better form copy. It is that a value and how you got it are one piece of information, and splitting them throws half away.&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;"browser"&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;"value"&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 141.0.7390.55"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"measured"&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;"os"&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"macOS 15.6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"measured"&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;"steps"&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stated"&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;"started"&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;"value"&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-09-22T13:05:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stated"&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;Four fields, and a reader now knows which two to argue with. Nothing about the values changed. What changed is that the ticket stopped presenting a guess and a reading as the same kind of thing.&lt;/p&gt;

&lt;p&gt;I did not come up with that framing. A reader named Sven Andreas put it in a comment on an earlier post here, describing an open-source reporting client he is building: it reads the values the project declares, shows each one to the reporter before sending, and &lt;strong&gt;labels it as measured rather than typed&lt;/strong&gt;. That last clause is the part worth stealing. Collecting automatically is common. Saying so in the artefact is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement you get for free afterwards
&lt;/h2&gt;

&lt;p&gt;Once the two are distinguishable, you can compare them, and the comparison is the useful bit.&lt;/p&gt;

&lt;p&gt;Ask for the browser &lt;em&gt;and&lt;/em&gt; measure it. Then count how often they disagree. If the typed answer matches the measured one 95% of the time, stop asking, the question is noise in your form. If it matches 60% of the time, you have just learned that the field has been quietly poisoning triage for years, and roughly how much.&lt;/p&gt;

&lt;p&gt;You cannot run that comparison on a form that merges them into one box, which most forms do, including the default one in most trackers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap version
&lt;/h2&gt;

&lt;p&gt;You do not need a schema change to start.&lt;/p&gt;

&lt;p&gt;Keep them as two fields, not one. "Browser (detected)" and "Browser (as reported)". Let them disagree on the ticket rather than resolving the disagreement in the form, which is the moment the information is destroyed. Whoever picks the ticket up can see both, and the gap between them is often the fastest read on the whole report: a person who believes they are on Safari 26 while the string says 27.0 has just told you what kind of week you are having.&lt;/p&gt;

&lt;p&gt;And for the fields that can only ever be stated, steps, expectations, what they were trying to do, mark them as such and stop apologising for them. A stated field is not a worse field. It is a different kind of claim, and the only failure is presenting it as the other kind.&lt;/p&gt;

&lt;p&gt;The unlabelled ticket is the one that costs you, because it asks you to trust everything on it equally, and you will.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>discuss</category>
      <category>ux</category>
    </item>
    <item>
      <title>Your Error Tracker Has Never Seen Your Worst Failures</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:57:24 +0000</pubDate>
      <link>https://dev.to/session_replay/your-error-tracker-has-never-seen-your-worst-failures-4o0p</link>
      <guid>https://dev.to/session_replay/your-error-tracker-has-never-seen-your-worst-failures-4o0p</guid>
      <description>&lt;p&gt;Every in-page monitoring tool has the same blind spot, and it is not a bug in any of them. Error trackers, analytics, session recorders, the browser extension I work on: all of them are JavaScript that runs on your page.&lt;/p&gt;

&lt;p&gt;So none of them can tell you anything about the times your page did not run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failures that leave no trace in the browser
&lt;/h2&gt;

&lt;p&gt;Think about what the user sees in each of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DNS does not resolve. &lt;code&gt;DNS_PROBE_FINISHED_NXDOMAIN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The connection is refused or times out. &lt;code&gt;ERR_CONNECTION_REFUSED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The certificate does not validate. &lt;code&gt;NET::ERR_CERT_AUTHORITY_INVALID&lt;/code&gt;, and a full-page warning.&lt;/li&gt;
&lt;li&gt;The CDN returns a 502 from its own error page, not yours.&lt;/li&gt;
&lt;li&gt;A corporate proxy or a DNS filter blocks the domain outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every one of them the browser shows its own page. That page is not your site. Your HTML never parsed, your bundle never downloaded, your snippet never initialised, and there is no place for a &lt;code&gt;window.onerror&lt;/code&gt; handler to exist, let alone fire.&lt;/p&gt;

&lt;p&gt;Which means your error tracker records nothing. Not a zero. Nothing: no event, no session, no user. The dashboard does not go red. It goes quiet in a way that is indistinguishable from everybody having a lovely time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half that surprises people is the server side
&lt;/h2&gt;

&lt;p&gt;The instinct is that at least the server knows. Often it does not.&lt;/p&gt;

&lt;p&gt;A DNS failure never produces a connection, so nothing reaches you at all. A blocked domain is the same. A refused connection never becomes a request. A TLS handshake that fails never becomes an HTTP request either, so it does not appear in your application's access log, which only ever sees requests that got that far. Depending on how the terminating proxy is configured, a handshake failure may appear in &lt;em&gt;its&lt;/em&gt; error log, which is a different file that most teams read only when they already suspect something.&lt;/p&gt;

&lt;p&gt;So for this class of failure you have two instruments, and both are pointed the wrong way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;in-page monitoring   → requires your page to run     → it did not
application logs     → require a completed request   → there was none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overlap of those two blind spots is the set of users who could not reach you. They are, by definition, the users least represented in every number you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is survivorship bias with a build step
&lt;/h2&gt;

&lt;p&gt;Your metrics describe the people who got in. Uptime checks from a monitoring service describe a machine in a datacentre that has clean DNS, no proxy, a correct clock and a fresh certificate store. Both are useful. Neither is evidence about the person whose employer's MITM proxy rejected your chain this morning.&lt;/p&gt;

&lt;p&gt;That gap has a shape worth remembering: &lt;strong&gt;the failures that are invisible to you are disproportionately the ones that are specific to the user rather than to your server.&lt;/strong&gt; A clock that is a year out. A resolver that is being filtered. A network that intercepts TLS. Your server is fine for everyone else, your instruments agree, and one person is locked out with a red screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually finds them
&lt;/h2&gt;

&lt;p&gt;Three things, none of which is your error tracker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check from outside, from more than one place.&lt;/strong&gt; Not a browser you have used before: one that has never fetched your intermediate certificate, on a network that is not yours. External checkers exist precisely because your own machine is a contaminated instrument.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the layer below the application.&lt;/strong&gt; Handshake failures, connection resets and blocked requests live in the proxy or load balancer logs, not the app's. If you have never looked at that file, you do not know whether it is empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take the report seriously when it arrives.&lt;/strong&gt; This is the awkward one. For this class of failure the only signal that exists is a human being telling you, and they will tell you badly, because there is nothing for them to copy. No console, no network tab, no error id. Just a red page and a sentence.&lt;/p&gt;

&lt;p&gt;That is the part worth internalising. Most of the time, "the report is vague" is a process problem you can fix by asking for more. Here it is a physical constraint: the browser refused before anything of yours existed, so there is nothing to collect, and the vague sentence is the whole of the evidence that will ever exist.&lt;/p&gt;

&lt;p&gt;Which changes what to do with it. Not "please reproduce it with the console open". Instead: what exact words were on the screen, what time does their device say it is, does it happen on mobile data. Three questions that work precisely because they do not assume your page ever loaded.&lt;/p&gt;

&lt;p&gt;Your dashboards will still be green while you ask them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>monitoring</category>
      <category>discuss</category>
    </item>
    <item>
      <title>We Shipped a Week of Posts With Images. We Cannot Prove a Single Image Arrived.</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Fri, 18 Sep 2026 12:57:12 +0000</pubDate>
      <link>https://dev.to/session_replay/we-shipped-a-week-of-posts-with-images-we-cannot-prove-a-single-image-arrived-4g9b</link>
      <guid>https://dev.to/session_replay/we-shipped-a-week-of-posts-with-images-we-cannot-prove-a-single-image-arrived-4g9b</guid>
      <description>&lt;p&gt;Our social posts go out through a script. It uploads an image, gets a media id back, then creates the post with that id attached. Every run prints the URL of the post it made, and for a week I reported those URLs as "posted, with the cover".&lt;/p&gt;

&lt;p&gt;Then a colleague found that a video post of theirs had gone out as plain text. The API had accepted it. It had returned a perfectly normal id. The clip was simply not there.&lt;/p&gt;

&lt;p&gt;So I went to check ours, and could not. Which is the part worth writing down, because neither half of that is unusual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two calls, one of which cannot fail
&lt;/h2&gt;

&lt;p&gt;The shape is everywhere: upload the thing, then reference it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /media        -&amp;gt; { media_id: "1234" }
POST /posts        { text: "...", media_ids: ["1234"] }  -&amp;gt; { id: "5678" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second call validates what it was given. The id is a well-formed string, so the request is well-formed, so the post is created and you get an id back. Whether that media id points at anything usable, whether the upload finished processing, whether your code even put the field in the payload on this code path: none of that is the create call's business.&lt;/p&gt;

&lt;p&gt;You get a 200 and an identifier either way. &lt;strong&gt;The identifier is proof that a post exists. It is not proof of what is in it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In our case the immediate cause was mundane. The &lt;code&gt;--video&lt;/code&gt; flag reached only one branch of the posting code; the other branch accepted an image parameter and silently ignored a video one. A missing field is not an error to an endpoint that treats every field as optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it returned an id" feels like proof
&lt;/h2&gt;

&lt;p&gt;Because for single-call operations it nearly is. &lt;code&gt;POST /users&lt;/code&gt; returning an id does mean a user exists with the fields you sent, because there was only one call and one set of fields.&lt;/p&gt;

&lt;p&gt;The intuition breaks the moment an operation spans two calls, and it breaks silently, because nothing in the second response mentions the first. There is no &lt;code&gt;media_attached: false&lt;/code&gt;. The response shape is identical.&lt;/p&gt;

&lt;p&gt;That is the same reason a compound operation needs a different kind of assertion:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;media_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mediaId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Not done yet. Read it back and assert on the property you wanted,&lt;/span&gt;
&lt;span class="c1"&gt;// not on the fact that something was created.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?expansions=attachments.media_keys`&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;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;media_keys&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="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="s2"&gt;`post &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; went out without its media`&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;Four lines. The reason they did not exist is that everything looked like it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I actually changed
&lt;/h2&gt;

&lt;p&gt;My first instinct was to add "verify the attachment after posting" to the runbook. A colleague's phrasing was better, and it is the thing I would pass on: &lt;strong&gt;do not claim the image attached when what you have is an id.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The difference is where the burden sits. "Verify afterwards" is a step you can forget, and forgetting it leaves the false claim in place. "Do not claim it" makes the unverified state the default, so forgetting to check means saying less, not saying something untrue. One of those degrades safely.&lt;/p&gt;

&lt;p&gt;So the reports now say "posted". Not "posted with the cover", until there is a read-back that proves it. Our read-back is currently blocked by an unrelated signing bug in the same script, which is its own small lesson: the verification path and the action path are different code, and the one you never run is the one that is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops being about posting
&lt;/h2&gt;

&lt;p&gt;Every bug report that contains the phrase "and it said it worked" is this.&lt;/p&gt;

&lt;p&gt;The person clicked submit. Something returned a success state. A green tick appeared, or a confirmation page, or a toast. None of that is evidence the operation completed, because the confirmation was rendered by the same layer that would have needed to know it failed, and often that layer only knows a request was accepted.&lt;/p&gt;

&lt;p&gt;Which means the sentence "I submitted it and it said it was fine" carries about as much information as our tweet id did. It is real, the person is not mistaken about what they saw, and it says nothing about whether the thing arrived.&lt;/p&gt;

&lt;p&gt;The useful version of that report is not a better description of the confirmation. It is the request and the response that produced it, captured at the time, from the machine where it happened. Everything else is a screenshot of a green tick.&lt;/p&gt;

&lt;p&gt;We had a week of green ticks. They were all genuine, and I still cannot tell you what was in the posts.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>debugging</category>
      <category>discuss</category>
    </item>
    <item>
      <title>"It Broke Around 3pm" Is Not a Timestamp</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:57:37 +0000</pubDate>
      <link>https://dev.to/session_replay/it-broke-around-3pm-is-not-a-timestamp-55i1</link>
      <guid>https://dev.to/session_replay/it-broke-around-3pm-is-not-a-timestamp-55i1</guid>
      <description>&lt;p&gt;A user tells you the checkout failed around 3pm yesterday. You open the logs, search 14:30 to 15:30, find nothing unusual, and reply that you cannot see anything on your side.&lt;/p&gt;

&lt;p&gt;Three separate things in that sentence can be wrong, and none of them is the user being careless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways their 3pm is not your 3pm
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;They are in a different timezone from your logs.&lt;/strong&gt; Yours are UTC, because everybody's are. Theirs is wall-clock local, and they did not say which. Half of Europe is one hour off UTC in winter and two in summer, so "3pm" from an unspecified reporter is a window of at least a day if they are global, and an hour wrong if you guessed their country right and the DST boundary wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Their clock is wrong.&lt;/strong&gt; Not metaphorically. A device whose time sync is off, a VM resumed from a snapshot, a phone that has been in aeroplane mode since Tuesday, a desktop where someone set the clock by hand to make a licence work. &lt;code&gt;Date.now()&lt;/code&gt; in the browser reads that clock. MDN puts it plainly: &lt;code&gt;Date.now()&lt;/code&gt; "may have been impacted by system and user clock adjustments, clock skew, etc. as it is relative to the Unix epoch and dependent on the system clock."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Around" is doing real work.&lt;/strong&gt; People round to the nearest interesting number. "Around 3" covers 14:40 to 15:20 in practice, and if the thing they remember is "just after lunch", it covers two hours.&lt;/p&gt;

&lt;p&gt;Stack those and your one-hour log search can miss the event by half a day. You conclude nothing happened. Something happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the browser can tell you instead
&lt;/h2&gt;

&lt;p&gt;All three are solvable, but only by something running on their machine at the moment it breaks, because every one of them is a property of their machine and not of their memory.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;timeFacts&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="c1"&gt;// Their clock. Wrong sometimes, but it is the clock that produced&lt;/span&gt;
    &lt;span class="c1"&gt;// any timestamp they will ever quote at you, so capture it as-is.&lt;/span&gt;
    &lt;span class="na"&gt;clientTime&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

    &lt;span class="c1"&gt;// What their calendar thinks, so "3pm" can be resolved.&lt;/span&gt;
    &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// "Europe/Madrid"&lt;/span&gt;
    &lt;span class="na"&gt;offsetMinutes&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getTimezoneOffset&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;

    &lt;span class="c1"&gt;// Monotonic, immune to clock changes: MDN, "its current time never&lt;/span&gt;
    &lt;span class="c1"&gt;// decreases and isn't subject to adjustments". Useless across machines,&lt;/span&gt;
    &lt;span class="c1"&gt;// perfect for ordering events within one session.&lt;/span&gt;
    &lt;span class="na"&gt;sinceLoad&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;The fourth value is the one that closes the case, and it does not come from the browser at all. Every HTTP response carries a &lt;code&gt;Date&lt;/code&gt; header, which is the server's own time, in GMT, formatted by the server. Read it off any same-origin response:&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/ping&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;serverTime&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;Date&lt;/span&gt;&lt;span class="p"&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;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="s1"&gt;Date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// their request, your clock&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;skewMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;serverTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// how wrong their clock is&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Cross-origin you have to expose it with &lt;code&gt;Access-Control-Expose-Headers: Date&lt;/code&gt; first.)&lt;/p&gt;

&lt;p&gt;Now "around 3pm" becomes an instant on your clock. Their timestamp, plus the skew you measured, lands in your logs. If the skew is forty minutes, you know that before you search, instead of concluding after an hour that the bug is imaginary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;Because the report is a memory, and memories are in local wall time with the precision of somebody who was not expecting to be asked.&lt;/p&gt;

&lt;p&gt;You can write a longer form. "Please give the exact time and your timezone" produces a more confident wrong answer, because the person reads their own clock, which is the clock that was wrong. There is no phrasing that fixes a device whose time is off by forty minutes, and no amount of care from a reporter who has no reason to suspect their machine of lying.&lt;/p&gt;

&lt;p&gt;The only reliable version of this information is measured at the moment of failure, on the machine where it failed, and sent along with everything else. Four fields, one fetch, and a class of "cannot reproduce, nothing in the logs" simply stops happening.&lt;/p&gt;

&lt;p&gt;Worth the twenty minutes next time you build a report form. The alternative is searching the wrong hour and believing the result.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>javascript</category>
      <category>discuss</category>
    </item>
    <item>
      <title>"Send Me the Network Tab" Asks for Evidence That No Longer Exists</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:58:01 +0000</pubDate>
      <link>https://dev.to/session_replay/send-me-the-network-tab-asks-for-evidence-that-no-longer-exists-2075</link>
      <guid>https://dev.to/session_replay/send-me-the-network-tab-asks-for-evidence-that-no-longer-exists-2075</guid>
      <description>&lt;p&gt;Somebody reports that your app failed to load their dashboard. You send the standard reply: open DevTools, go to the Network tab, reproduce it, screenshot what you see.&lt;/p&gt;

&lt;p&gt;They do exactly that. The screenshot shows a healthy list of requests, all 200s, nothing red. So you close the ticket as not reproducible, and they get annoyed, because the page really was broken.&lt;/p&gt;

&lt;p&gt;Both of you are looking at evidence that was collected after the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The panel is not a recorder, it is a listener
&lt;/h2&gt;

&lt;p&gt;From Chrome's own Network panel reference: "By default, DevTools records all network requests in the Network panel, so long as DevTools is open." And from the panel's own getting-started page: "DevTools only logs network activity while it's open."&lt;/p&gt;

&lt;p&gt;There is no buffer behind that. The Network panel is not reading a log the browser was keeping anyway; it attaches when you open it and records from that moment. Everything the page did before, including the request that failed, was never written down by anything.&lt;/p&gt;

&lt;p&gt;So the instruction "open DevTools and reproduce" is asking for the second occurrence of the bug, not the first. Which is fine when the bug is deterministic, and useless when it is not, and the whole category of bugs that arrive as "it worked when I tried again" is exactly the non-deterministic kind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cold cache. The first load fetched something the second load had.&lt;/li&gt;
&lt;li&gt;A race at startup, which the second attempt loses less often because a connection is already warm.&lt;/li&gt;
&lt;li&gt;An expired token that the failing request refreshed as a side effect, so the retry is authenticated and the original was not.&lt;/li&gt;
&lt;li&gt;A rate limit that has since reset.&lt;/li&gt;
&lt;li&gt;Anything that only happens on the &lt;em&gt;first&lt;/em&gt; navigation of a session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those reproduces perfectly, once, on somebody else's machine, and leaves no trace by the time the panel is listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the log clears itself
&lt;/h2&gt;

&lt;p&gt;The second half is worse, because it discards evidence the panel did record.&lt;/p&gt;

&lt;p&gt;Chrome again, on the same page: "To save requests across page loads, check the Preserve log checkbox on the Network panel." It is off by default. Any navigation clears the list. The Console behaves the same way: "By default the Console clears whenever you load a new page."&lt;/p&gt;

&lt;p&gt;Now think about what a login bug looks like. The user submits the form, gets bounced through two redirects, lands somewhere wrong. By the time the final page has settled, the network log contains that final page's requests and nothing else. The 302 chain you actually need is gone. The user screenshots what is on screen, which is a perfectly healthy page load, and you conclude that the POST never happened.&lt;/p&gt;

&lt;p&gt;The same applies to any bug whose last act is a navigation: a failed checkout that redirects to an error page, an OAuth callback, a form that reloads on submit. The evidence deletes itself at the exact moment the user reaches for the camera.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to ask for instead
&lt;/h2&gt;

&lt;p&gt;If you are going to ask a person to do this, ask for all of it, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools &lt;strong&gt;before&lt;/strong&gt; the bug, not after.&lt;/li&gt;
&lt;li&gt;Network panel, tick &lt;strong&gt;Preserve log&lt;/strong&gt;. Console settings, tick &lt;strong&gt;Preserve log&lt;/strong&gt; there too.&lt;/li&gt;
&lt;li&gt;In Network settings, tick &lt;strong&gt;Capture screenshots&lt;/strong&gt; if the bug is visual, which gives filmstrip frames lined up against the requests.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Now&lt;/em&gt; reproduce.&lt;/li&gt;
&lt;li&gt;Right-click any row, &lt;strong&gt;Save all as HAR with content&lt;/strong&gt; rather than screenshotting. A screenshot of a list is a list; a HAR is the requests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is five steps and three settings that a non-technical reporter will not complete, which is the honest reason most teams send the one-line version and accept the useless screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that does not have a workaround
&lt;/h2&gt;

&lt;p&gt;Steps 1 through 5 all assume the person knew the bug was coming.&lt;/p&gt;

&lt;p&gt;Nobody does. The failure happens, and &lt;em&gt;then&lt;/em&gt; someone decides it is worth reporting, and by then the only instrument that could have recorded it was switched off. You can ask them to reproduce, and for a deterministic bug that works. For everything else, the report you needed had to be collected by something that was already running when the page broke, which is a different requirement from anything a support reply can contain.&lt;/p&gt;

&lt;p&gt;Worth knowing before the next "cannot reproduce". The person did not fail to gather evidence. The evidence was never gathered, by anyone, including you.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>devtools</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Every Browser on an iPhone Is Safari, and the Bug Report Will Not Say Which One</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Tue, 15 Sep 2026 12:58:02 +0000</pubDate>
      <link>https://dev.to/session_replay/every-browser-on-an-iphone-is-safari-and-the-bug-report-will-not-say-which-one-17kj</link>
      <guid>https://dev.to/session_replay/every-browser-on-an-iphone-is-safari-and-the-bug-report-will-not-say-which-one-17kj</guid>
      <description>&lt;p&gt;Safari 27 shipped on Monday with iOS 27. By the weekend most iPhones will be on it, and most of their owners will not know. That is the ordinary story of a browser release. The less ordinary part is how many "browsers" that one update just changed, and how badly a bug report describes any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, many names
&lt;/h2&gt;

&lt;p&gt;Apple's App Store Review Guidelines, 2.5.6: "Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript." An entitlement for an alternative engine exists for the EU and Japan; outside a handful of apps in those markets, the rule is the rule. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome on iPhone is WebKit with Chrome's toolbar. Same for Firefox, Edge, Brave, Opera.&lt;/li&gt;
&lt;li&gt;The browser inside Instagram, Facebook, Slack, Gmail, TikTok and LinkedIn is &lt;code&gt;WKWebView&lt;/code&gt;, a system component. It is WebKit with whatever settings the app chose.&lt;/li&gt;
&lt;li&gt;"Open in Safari" from those apps is the actual Safari.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of those updated overnight with iOS 27, because WebKit is part of the OS, not part of the app. The Instagram in-app view that renders your page got scroll anchoring and eight Content Security Policy fixes on Monday, and Instagram shipped nothing.&lt;/p&gt;

&lt;p&gt;When a report says "Chrome on my iPhone", the engine is Safari's, and reproducing it in Chrome on your desktop reproduces the wrong one. When it says "I opened it from Instagram", the engine is Safari's too, but the feature set is not: no extensions, no address bar, the app's own cookie jar, and often no way to get to the tab in any other browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the user agent actually tells you
&lt;/h2&gt;

&lt;p&gt;The reporter cannot tell you which of those they were in. The user agent string can, if you know its shape. Three tokens do most of the work:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;iosSurface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&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="sr"&gt;/iPhone|iPad|iPod/&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;ua&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isIpadPretendingToBeAMac&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/CriOS&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&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;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chrome-ios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// still WebKit&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/FxiOS&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&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;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;firefox-ios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// still WebKit&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/EdgiOS&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&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;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge-ios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// still WebKit&lt;/span&gt;

  &lt;span class="c1"&gt;// Safari names itself twice: "Version/27.0 ... Safari/604.1".&lt;/span&gt;
  &lt;span class="c1"&gt;// A WKWebView sends neither token. That absence is the whole signal.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Version&lt;/span&gt;&lt;span class="se"&gt;\/[\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+.*Safari&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&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;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;safari&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in-app-webview&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="c1"&gt;// Since iPadOS 13, Safari on an iPad asks for desktop sites by default and&lt;/span&gt;
&lt;span class="c1"&gt;// says it is a Mac. The touch points give it away.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isIpadPretendingToBeAMac&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MacIntel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxTouchPoints&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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;Two of those lines are the ones that cost people afternoons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The missing &lt;code&gt;Safari/&lt;/code&gt; token.&lt;/strong&gt; A &lt;code&gt;WKWebView&lt;/code&gt; user agent reads something like &lt;code&gt;Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148&lt;/code&gt;. No &lt;code&gt;Version/&lt;/code&gt;, no &lt;code&gt;Safari/&lt;/code&gt;. Most user agent parsers, handed that, print "Safari" anyway, because WebKit on iPhone is Safari as far as they are concerned. So your analytics say Safari, your error tracker says Safari, and the failure only happens inside the Instagram browser, where the app has disabled something you rely on. Apps do append their own markers (&lt;code&gt;Instagram&lt;/code&gt;, &lt;code&gt;FBAN/FBIOS&lt;/code&gt;, &lt;code&gt;LinkedInApp&lt;/code&gt;), but not all of them, and not consistently; the absent tokens are the reliable test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The iPad that says it is a Mac.&lt;/strong&gt; A report of "Safari on Mac, buttons do not respond" can be an iPad. It sends a &lt;code&gt;Macintosh; Intel Mac OS X&lt;/code&gt; user agent, gets your desktop layout at 1024 points wide, and then tries to tap hover menus with a finger. Every hover-only interaction on the page is broken for that user, and the report will say Mac. &lt;code&gt;maxTouchPoints&lt;/code&gt; is the only honest witness.&lt;/p&gt;

&lt;p&gt;One more thing the string will never tell you: &lt;code&gt;navigator.userAgentData&lt;/code&gt;, the structured replacement, is &lt;code&gt;undefined&lt;/code&gt; on every one of the surfaces above. WebKit has not shipped it. Code that feature-detects the new API and treats its absence as "an old browser" has just classified every iPhone on earth as old.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a reporting problem before it is a detection problem
&lt;/h2&gt;

&lt;p&gt;The function above is useful in your own telemetry. It is useless in the moment that matters, which is when a person types "does not work on my phone" into a form and hits send.&lt;/p&gt;

&lt;p&gt;That person cannot see the user agent. They do not know that the Chrome icon they tapped opens Safari's engine, or that the link they followed from a Slack message never left Slack. They will answer "Chrome" or "Safari" or "my iPhone" in good faith, and every one of those answers points the developer at the wrong reproduction.&lt;/p&gt;

&lt;p&gt;So the report has to carry the string itself, captured from the page at the moment of the failure, along with the viewport and the touch points. Not because the reporter should have known, but because they could not have. Anything that asks them to describe their browser is asking a question they are structurally unable to answer correctly on iOS, and this week, with one engine update landing on every surface at once, the wrong answer costs a reproduction that will never succeed.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>ios</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your `window.onerror` Handler Has Never Seen a Rejected Promise</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:57:49 +0000</pubDate>
      <link>https://dev.to/session_replay/your-windowonerror-handler-has-never-seen-a-rejected-promise-4il2</link>
      <guid>https://dev.to/session_replay/your-windowonerror-handler-has-never-seen-a-rejected-promise-4il2</guid>
      <description>&lt;p&gt;There is a class of failure that most hand-rolled error tracking has been blind to since promises arrived, and the reason is structural rather than a bug: a rejected promise is not an exception. It is a value. Nothing is thrown, so nothing is caught, and the handler you wired up in 2015 to &lt;code&gt;window.onerror&lt;/code&gt; sits there reporting a healthy page while the async half of your application fails quietly beside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two channels, not one
&lt;/h2&gt;

&lt;p&gt;The browser has two separate mechanisms for telling you something went wrong, and they do not overlap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous exceptions&lt;/strong&gt; go through &lt;code&gt;window.onerror&lt;/code&gt; (or an &lt;code&gt;error&lt;/code&gt; event listener). Anything thrown and not caught in a script, an event handler, a timer callback, arrives here with a message, a filename, a line and column, and the &lt;code&gt;Error&lt;/code&gt; object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rejected promises with no handler&lt;/strong&gt; go through a different event entirely. MDN's wording:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;unhandledrejection&lt;/code&gt; event is sent to the global scope when a JavaScript Promise that has no rejection handler is rejected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Note the phrase &lt;em&gt;no rejection handler&lt;/em&gt;. A promise rejection is not an error condition in JavaScript. It is a normal state a promise can be in, and it only becomes a problem when nothing is listening. The runtime waits until the end of the current task to see whether a &lt;code&gt;.catch()&lt;/code&gt; turns up, and if none does, it fires &lt;code&gt;unhandledrejection&lt;/code&gt;. Not &lt;code&gt;error&lt;/code&gt;. Nothing is thrown at any point.&lt;/p&gt;

&lt;p&gt;Which means this catches nothing from a failed &lt;code&gt;fetch&lt;/code&gt;, a rejected &lt;code&gt;async&lt;/code&gt; function, or an &lt;code&gt;await&lt;/code&gt; inside code with no &lt;code&gt;try&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;col&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;send&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="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stack&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="nx"&gt;stack&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 this is what was missing:&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="nb"&gt;window&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;unhandledrejection&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;event&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;
  
  
  Why it is worse than a missing handler
&lt;/h2&gt;

&lt;p&gt;Three things about the second channel make it easy to get wrong even once you know it exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;reason&lt;/code&gt; can be anything.&lt;/strong&gt; A synchronous throw almost always carries an &lt;code&gt;Error&lt;/code&gt;, so there is a stack. A promise can be rejected with a string, a number, &lt;code&gt;undefined&lt;/code&gt;, a response object, whatever somebody passed to &lt;code&gt;reject()&lt;/code&gt;. &lt;code&gt;event.reason.stack&lt;/code&gt; is a property access on a value that may not be an object. Half the rejections in a real codebase carry no stack at all, because the code that produced them never constructed an &lt;code&gt;Error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;preventDefault()&lt;/code&gt; silences it.&lt;/strong&gt; The event is cancelable, and MDN is explicit about what cancelling does:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Allowing the &lt;code&gt;unhandledrejection&lt;/code&gt; event to bubble will eventually result in an error message being output to the console. You can prevent this by calling &lt;code&gt;preventDefault()&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Some libraries do exactly that, to keep the console clean. If one of them loaded before your tracking did, and it calls &lt;code&gt;preventDefault()&lt;/code&gt; without forwarding, your handler runs but the console entry that would have alerted a developer never appears. The failure is now recorded nowhere a human looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The timing is different.&lt;/strong&gt; An &lt;code&gt;error&lt;/code&gt; event fires at the throw. &lt;code&gt;unhandledrejection&lt;/code&gt; fires at the end of the task, after the rejection has propagated through whatever chain it was in. The stack, if there is one, points at where the promise was created, not at where it failed to be handled. For a &lt;code&gt;fetch&lt;/code&gt; that rejected because the network dropped, that is a frame in a helper that wraps every request, which tells you which of two hundred calls failed exactly as well as a 404 page tells you which file is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually bites
&lt;/h2&gt;

&lt;p&gt;The pattern is a page that works, with a feature that silently does not.&lt;/p&gt;

&lt;p&gt;A button calls an &lt;code&gt;async&lt;/code&gt; function. The function &lt;code&gt;await&lt;/code&gt;s a request. The request fails. Nothing was thrown synchronously, so &lt;code&gt;window.onerror&lt;/code&gt; is not involved. The promise rejects with a &lt;code&gt;TypeError: Failed to fetch&lt;/code&gt;, nothing catches it, and the button does nothing. The user sees a button that does nothing. The error tracker sees a healthy page.&lt;/p&gt;

&lt;p&gt;The user reports: "the save button is broken". The developer clicks save, it works, and closes the ticket. There was an error, it had a message, and it went down a channel nobody had connected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check
&lt;/h2&gt;

&lt;p&gt;Open your production page, and in the console:&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&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;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;canary&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;Then look at what your tracker recorded. If nothing arrived, every async failure on that page has been arriving the same way for as long as the tracking has existed. If something arrived but with no stack, that is the &lt;code&gt;reason&lt;/code&gt;-is-not-an-Error problem, and it is worth normalising at the boundary:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if your handler runs but nothing shows in the console, something upstream is calling &lt;code&gt;preventDefault()&lt;/code&gt;, and you have a library to find.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It is the difference between the errors that were thrown and the errors that merely happened, and only one of those was ever wired to the thing you check.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>async</category>
    </item>
    <item>
      <title>"Cannot Reproduce" Often Means "Different Build", and Four Places That Happens</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Fri, 11 Sep 2026 12:32:45 +0000</pubDate>
      <link>https://dev.to/session_replay/cannot-reproduce-often-means-different-build-and-four-places-that-happens-m06</link>
      <guid>https://dev.to/session_replay/cannot-reproduce-often-means-different-build-and-four-places-that-happens-m06</guid>
      <description>&lt;p&gt;I published something this morning saying a feature had shipped. It had not. The work was real, the commits were real, and they were sitting on a branch that was not &lt;code&gt;main&lt;/code&gt;. I had read a &lt;code&gt;git log&lt;/code&gt; without checking what the repository was checked out to, and written an announcement off the answer.&lt;/p&gt;

&lt;p&gt;The narrow lesson is boring: look at your branch. The general one is not, because the same gap sits underneath one of the most common ways a bug report dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Works on my machine" has a version axis
&lt;/h2&gt;

&lt;p&gt;The environment version of that sentence is well worn: different OS, different data, no network latency, a case-insensitive filesystem. That is real and it is covered everywhere.&lt;/p&gt;

&lt;p&gt;The version axis gets much less attention, and it is simpler to fall into. You are not running the code your user is running. There are at least four places the two can diverge, and each one is invisible from where you sit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your working tree is not the default branch.&lt;/strong&gt; The commit exists. It is in your log. It is not in anybody else's build. This is the one I hit today, and the tell is that everything looks merged when you are standing on the branch that has it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The default branch is not released.&lt;/strong&gt; Merged is not published. There is a release train, a store review, an approval queue, a deploy window. For a browser extension the gap between "merged to main" and "available to users" is measured in days and decided by somebody else's reviewer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Released is not installed.&lt;/strong&gt; Auto-update is a schedule, not an event. A fraction of your users are on the previous version at any moment, and the fraction is larger than you would guess for anything installed rather than served.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Installed is not running.&lt;/strong&gt; This is the one people miss entirely. Chrome stages an extension or browser update and applies it at restart. A tab that has been open for a fortnight is running what it launched with, no matter how many times the update was fetched in the background. The person reporting your bug may have the fix on disk and not in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this closes tickets that should stay open
&lt;/h2&gt;

&lt;p&gt;"Cannot reproduce" is one of the most common ways a report ends. The path to it is short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user reports something broken.&lt;/li&gt;
&lt;li&gt;You check out the default branch, run it, and the problem is not there.&lt;/li&gt;
&lt;li&gt;You close the ticket, reasonably, because you looked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step two is where the version axis hides. You ran a build nobody outside your machine has. If the bug was fixed last week on a branch, or fixed on main but not released, or released but not yet installed, or installed but not yet running in their tab, then &lt;em&gt;both of you are right&lt;/em&gt; and the disagreement is about which program you are each talking about.&lt;/p&gt;

&lt;p&gt;Nothing in the report tells you that, because the report contains what the user could see, and a version number is not something anybody thinks to look up while annoyed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring and it is the version
&lt;/h2&gt;

&lt;p&gt;Make the report carry the build, automatically, and treat it as a required field rather than a nice-to-have. The extension or app version, the browser version, and ideally the commit the server was running when the request happened.&lt;/p&gt;

&lt;p&gt;And be careful with "latest". Users say "latest" and mean "I have not deliberately stopped updates". It is not a version, and treating it as one is how you end up debugging a build nobody is on.&lt;/p&gt;

&lt;p&gt;If you own a browser extension, &lt;code&gt;chrome://extensions&lt;/code&gt; shows the installed version, and it can disagree with what is running in a tab that has been open a while. That is worth asking about explicitly rather than assuming a restart has happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;Our machines agree with us. You check out the branch you were working on, and it has your work. You run main, and the bug is gone. Every check you can run from where you are standing confirms what you already believe, which is exactly the shape of a mistake you do not catch.&lt;/p&gt;

&lt;p&gt;I wrote an announcement this morning that was confidently wrong, and every command I ran while writing it told me I was right. The only thing that fixed it was somebody who had the actual state of the world telling me otherwise.&lt;/p&gt;

&lt;p&gt;That is what a good bug report is: somebody handing you the state of the world from where they are standing, because from where you are standing it looks fine.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>git</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>"Script error." Is Not an Error. It Is Your Error Tracker Being Told to Shut Up.</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:29:39 +0000</pubDate>
      <link>https://dev.to/session_replay/script-error-is-not-an-error-it-is-your-error-tracker-being-told-to-shut-up-57pi</link>
      <guid>https://dev.to/session_replay/script-error-is-not-an-error-it-is-your-error-tracker-being-told-to-shut-up-57pi</guid>
      <description>&lt;p&gt;If you have ever opened your error dashboard and found a pile of entries reading &lt;code&gt;Script error.&lt;/code&gt; with no message, no stack, and line 0, column 0, you have probably filed it under "browser noise" and moved on.&lt;/p&gt;

&lt;p&gt;It is not noise. It is a real exception, thrown by real code, with everything useful deliberately removed before it reached you. And the class of code it happens to is the class most likely to break in ways you did not anticipate: the scripts you serve from somewhere other than your own origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually happening
&lt;/h2&gt;

&lt;p&gt;The HTML specification gives every classic script a &lt;strong&gt;muted errors&lt;/strong&gt; boolean. The wording is worth reading directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;if true, means that error information will not be provided for errors in this script. This is used to mute errors for cross-origin scripts, since that can leak private information.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the condition that sets it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Let mutedErrors be true if response was CORS-cross-origin, and false otherwise.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the whole mechanism. A script fetched from another origin, without CORS, is a script whose exceptions arrive at &lt;code&gt;window.onerror&lt;/code&gt; stripped: a fixed message, no filename, no line, no column, and &lt;code&gt;error&lt;/code&gt; set to null so there is no stack to walk.&lt;/p&gt;

&lt;p&gt;The reasoning is sound. An error message can contain anything the script was holding when it threw. If any page could load any cross-origin script and read its exception text, that is a side channel for reading things the origin never agreed to share. So the browser closes it by default.&lt;/p&gt;

&lt;p&gt;The problem is not the rule. The problem is that almost nobody realises the rule applies to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this actually hits
&lt;/h2&gt;

&lt;p&gt;Not "third-party widgets". You.&lt;/p&gt;

&lt;p&gt;The moment your own bundle is served from a CDN on a different hostname than your page - which is most production setups, and is exactly what every performance guide tells you to do - your application's own JavaScript is cross-origin to your application. Every uncaught exception it throws is a candidate for muting.&lt;/p&gt;

&lt;p&gt;Which produces the failure mode worth naming: &lt;strong&gt;your error tracking still works.&lt;/strong&gt; You keep getting errors, because inline scripts and same-origin scripts report normally. You just quietly stop getting them from the one bundle that contains most of your code. Nothing announces the gap. Your dashboard is not empty, it is filtered, and the filter is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is two lines and both are required
&lt;/h2&gt;

&lt;p&gt;On the tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.example.com/app.js"&lt;/span&gt; &lt;span class="na"&gt;crossorigin=&lt;/span&gt;&lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the response, from the CDN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Origin: https://www.example.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MDN is blunt about what happens without them: with no &lt;code&gt;crossorigin&lt;/code&gt; attribute, &lt;em&gt;"access to error logging via &lt;code&gt;window.onerror&lt;/code&gt; will be limited."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both halves are required, and this is where it usually goes wrong. Adding &lt;code&gt;crossorigin&lt;/code&gt; to the tag while the CDN sends no matching header does not get you better errors - it makes the request a CORS request that then fails, so the script does not load at all. A half-applied fix is worse than none, which is why this tends to get tried once, break something, and get reverted.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell whether you are affected, in about a minute
&lt;/h2&gt;

&lt;p&gt;Do not go looking in the dashboard for &lt;code&gt;Script error.&lt;/code&gt;, because absence there proves nothing. Cause one instead:&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;// In the console, on your production page:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&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;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;s&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://cdn.example.com/does-not-matter.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;throw new Error("canary")&lt;/span&gt;&lt;span class="dl"&gt;'&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;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still, add a deliberate &lt;code&gt;throw&lt;/code&gt; to a debug entry point in your real bundle, ship it behind a query flag, and read what your tracker records. If what arrives is &lt;code&gt;Script error.&lt;/code&gt; at 0:0, every production exception from that bundle has been arriving the same way, for as long as the bundle has been on that hostname.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I care about this more than the fix
&lt;/h2&gt;

&lt;p&gt;The interesting part is not the CORS header. It is that this is a monitoring system that fails by &lt;strong&gt;returning less&lt;/strong&gt;, not by returning nothing.&lt;/p&gt;

&lt;p&gt;An outage in your error tracker is obvious. A tracker that keeps working while silently dropping one category of error is not, because every check you would run against it comes back healthy. The errors are being reported. They are just being reported as nothing.&lt;/p&gt;

&lt;p&gt;That shape shows up all over debugging: the browser has the information, something between you and it decides you may not have it, and what reaches you is a summary that looks like an answer. When somebody tells you the page is broken and your tools show nothing, "nothing" deserves a second question. It may mean nothing happened. It may mean the thing that happened was not allowed to tell you.&lt;/p&gt;

&lt;p&gt;Those two look identical from where you are standing, and only one of them is good news.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Our Scheduler Never Ran for Six Days, and Every Check We Had Said It Was Fine</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:57:41 +0000</pubDate>
      <link>https://dev.to/session_replay/our-scheduler-never-ran-for-six-days-and-every-check-we-had-said-it-was-fine-3c04</link>
      <guid>https://dev.to/session_replay/our-scheduler-never-ran-for-six-days-and-every-check-we-had-said-it-was-fine-3c04</guid>
      <description>&lt;p&gt;We built a small posting scheduler: a YAML queue of things to publish, each with a time, and a runner that reads the queue and sends whatever is due. The runner keeps its own record of what it has already sent, so running it twice in a minute posts once. It has tests. It is about two hundred lines and none of them are clever.&lt;/p&gt;

&lt;p&gt;Then we wrote the launchd job that runs it into the README, as a fenced block, with the &lt;code&gt;launchctl&lt;/code&gt; command underneath. And moved on.&lt;/p&gt;

&lt;p&gt;Six days later somebody noticed the posts were not happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue looked perfect
&lt;/h2&gt;

&lt;p&gt;This is the part worth sitting with. Here is what the status command printed the morning we found out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fb-2026-09-02-smoke-testing   facebook  2026-09-02 09:12  queued
x-2026-09-03-cors             x         2026-09-03 15:07  queued
fb-2026-09-04-staging         facebook  2026-09-04 09:12  queued
x-2026-09-04-connection-errors x        2026-09-04 15:07  queued
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven entries, all valid, all with times in the past, all saying &lt;code&gt;queued&lt;/code&gt;. Nothing is wrong with any of them. &lt;code&gt;queued&lt;/code&gt; is exactly what the state should say for a post that has not gone out yet, and it is also exactly what it says when nothing has ever read the file.&lt;/p&gt;

&lt;p&gt;Those two situations are indistinguishable from inside the queue. A runner that has never executed and a runner with nothing to do produce identical output, because the evidence of the runner working is not stored anywhere the queue can see.&lt;/p&gt;

&lt;p&gt;The plist was never installed. &lt;code&gt;launchctl list | grep session-replay&lt;/code&gt; returned nothing, and had returned nothing since the day it was written. A documented snippet in a README reads exactly like a thing that is set up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check we would have run could not have caught it
&lt;/h2&gt;

&lt;p&gt;We had a health check in mind, and it was the wrong one: &lt;em&gt;is the queue healthy?&lt;/em&gt; Valid YAML, no duplicate ids, no entries pointing at articles that do not exist, no two entries at the same timestamp. All of that passed. All of it would have passed every day for six days.&lt;/p&gt;

&lt;p&gt;The queue was healthy. The queue was never the problem. The missing piece was one level up, in a place none of the queue's own diagnostics can reach, and no amount of validating the queue harder would have found it.&lt;/p&gt;

&lt;p&gt;The generalisable version: &lt;strong&gt;if the only evidence your scheduled job is running is the absence of a complaint, you have no evidence.&lt;/strong&gt; Silence is produced by working, by having nothing to do, and by not existing, and you cannot tell which one you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things that were quietly wrong
&lt;/h2&gt;

&lt;p&gt;Once it was installed, the schedule itself had a bug we had written into the README months before anybody could hit it. It ran hourly, on minute 7. A post timed for 09:12 would not go out until 10:07 - fifty-five minutes late, silently, forever.&lt;/p&gt;

&lt;p&gt;Worse, the X entries were written for 15:07, the same minute the runner fired. Whether the afternoon post went out on time or an hour late came down to which of the two won a race measured in milliseconds. It would have looked flaky and been almost impossible to reproduce deliberately.&lt;/p&gt;

&lt;p&gt;Both fixed by running every ten minutes on offsets nothing else uses: 2, 12, 22, 32, 42, 52.&lt;/p&gt;

&lt;p&gt;And the log was going to &lt;code&gt;/tmp&lt;/code&gt;, which macOS clears on reboot. So the one artefact that would have explained a failure was configured to delete itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixes this
&lt;/h2&gt;

&lt;p&gt;Not a better queue check. A &lt;strong&gt;positive trace&lt;/strong&gt;, written by the thing you care about, checked somewhere the thing itself cannot influence.&lt;/p&gt;

&lt;p&gt;Ours now logs every run, including the boring ones, to a path that survives a reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nothing due (20 in /Users/.../social/queue.yml)
nothing due (20 in /Users/.../social/queue.yml)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not noise. That is the runner saying &lt;em&gt;I exist and I looked&lt;/em&gt;, which is the exact fact the queue could not tell us. Forty of those lines and one posted entry is a healthy system. An empty file is a dead one, no matter how good the queue looks.&lt;/p&gt;

&lt;p&gt;One last trap while wiring this up, which cost twenty minutes: the first run after loading a launchd job produces nothing for a couple of minutes. launchd spawns through &lt;code&gt;xpcproxy&lt;/code&gt;, macOS scans the binary on first execution, and Ruby holds a redirected stdout in a buffer until the process exits. An empty log thirty seconds in reads exactly like a failure and is not one. &lt;code&gt;launchctl list &amp;lt;label&amp;gt;&lt;/code&gt; shows the PID while it is up, and &lt;code&gt;LastExitStatus&lt;/code&gt; once it is not. Check that, not the log, in the first two minutes.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>debugging</category>
      <category>cron</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Extension You Vetted Is Not the One That Auto-Updates</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:27:10 +0000</pubDate>
      <link>https://dev.to/session_replay/the-extension-you-vetted-is-not-the-one-that-auto-updates-a6c</link>
      <guid>https://dev.to/session_replay/the-extension-you-vetted-is-not-the-one-that-auto-updates-a6c</guid>
      <description>&lt;p&gt;We tell people to be careful which browser extensions they install. Check the reviews, look at the permissions, prefer the ones with a name behind them. It is good advice, and it quietly assumes something that is not true: that the extension you install is the extension you keep.&lt;/p&gt;

&lt;p&gt;It is not. Extensions auto-update, silently, in the background, from whoever controls the listing today - which is not necessarily whoever controlled it when you decided to trust it. The thing you vetted and the thing running in your browser next Tuesday can be two different programs, and nothing in the browser asks you again. The update channel, not the install, is the attack surface, and 2026 has been a long demonstration of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened, in one extension
&lt;/h2&gt;

&lt;p&gt;In December 2025, OX Security flagged a Chrome extension called "AI Sidebar with DeepSeek, ChatGPT, Claude and more" for scraping ChatGPT and DeepSeek conversations and sending them to external domains. By then it had over 300,000 installs and a 4.6-star rating. Google pulled it in January 2026.&lt;/p&gt;

&lt;p&gt;It came back. By August 2026 it was in the Chrome Web Store again, and Netskope Threat Labs caught a new version, 1.7.3.0, arriving through Google's own CDN between 20 and 31 July. The new payload was not stealing chats this time; it was monetizing the user. As Netskope described it, when Chrome reports that the extension has updated, a service worker opens an affiliate link in a foreground tab, routed through a URL shortener to an AI video platform. It went further: it exploited a Chrome quirk where the uninstall URL can be overwritten, so the extension earned an affiliate commission even as you removed it. It got paid on the way out.&lt;/p&gt;

&lt;p&gt;Nobody who had this installed did anything wrong. They installed a popular, well-reviewed tool, and an update turned it into something else while they were not looking. That is the whole problem in one story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the update is the soft spot
&lt;/h2&gt;

&lt;p&gt;An extension is not a static download. It is a listing with an owner, and two things about that owner change over time in ways the store does not surface to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ownership transfers.&lt;/strong&gt; An extension with a real userbase is an asset, and assets get sold. A developer who built something useful, got tired of maintaining it, and sold the listing to a buyer they never met has just transferred your trust to a stranger, and you will never see the transaction. Security firm Socket documented a wave of this in 2026: extensions acquired from their original creators and then, via ordinary automatic updates, injected with code to steal credentials and cryptocurrency. The users had vetted the original authors. The original authors were gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updates ship without re-review of the thing you agreed to.&lt;/strong&gt; You granted permissions once, to a version of the code you never read, and every version after it inherits those permissions. An extension with "read and change all your data on all websites" - a permission a great many legitimate extensions genuinely need - does not have to ask again to start doing something new with it. The consent was for the permission, not for any particular use of it, and an update is a new use.&lt;/p&gt;

&lt;p&gt;Manifest V3, which was supposed to be the big security reset, does not close this. It reworked how extensions run background code and it made some classes of remote code harder, but a service worker opening an affiliate tab on update is perfectly ordinary MV3, and so is exfiltrating data an extension is already permitted to read. MV3 changed the plumbing, not the trust model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you use extensions
&lt;/h2&gt;

&lt;p&gt;There is no browser setting that fixes a supply-chain problem, so the defenses are all about reducing exposure rather than detecting the bad update, which you cannot reliably do.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run fewer of them.&lt;/strong&gt; Every extension is a listing that can change hands, and the set you run is both your attack surface and, incidentally, a &lt;a href="https://dev.to/session_replay/a-web-page-can-tell-which-extensions-you-have-installed-here-is-how-553d"&gt;fingerprint a page can read&lt;/a&gt;. Uninstall the ones you stopped using; a dormant extension still updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate profiles by risk.&lt;/strong&gt; Keep the extension-heavy profile away from the one you bank and work in. A profile with three extensions is a smaller target than one with thirty, and a compromise in one does not sit in the same browser as your session cookies in the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for the tells of a sold listing:&lt;/strong&gt; a sudden update after a long quiet period, a change of developer name or support domain, new permissions requested on update, a once-focused tool that grows features nobody asked for. None of these is proof, and all of them are worth a second look before you keep it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it means if you build them
&lt;/h2&gt;

&lt;p&gt;The uncomfortable half: the trust your users place in you is transferable, and the store makes it easy to transfer. If you sell a listing, you are selling their trust to whoever buys it, and they will not be told. That is worth a moment's thought before the sale, and an honest note to your users is worth more than the extra few percent a buyer will pay for a silent handover.&lt;/p&gt;

&lt;p&gt;And if you are shipping updates: the permissions you were granted are a loan, not a licence. Every update is a chance to quietly widen what you do with data you were trusted to touch for one narrow reason. The users cannot see the diff. That asymmetry is exactly what the bad actors are exploiting, and it is the reason the whole category is under suspicion it did not used to be.&lt;/p&gt;




&lt;p&gt;Chrome Web Store enforcement did tighten in 2026 - stricter data-collection rules, closer review of whether an extension's privacy disclosures match its actual behavior, faster removals. It helps at the edges. But review happens at submission, and the attack happens at update, from an owner who looks legitimate because, until recently, they were. The gap between "I vetted this" and "this is what is running" is structural, and closing it is mostly on the two groups least inclined to think about it: the users who install and forget, and the authors who ship and move on.&lt;/p&gt;

&lt;p&gt;If you write extensions, the kindest thing you can do for the people who trusted you is to keep deserving it on every update - and, if you ever stop, to tell them rather than sell them.&lt;/p&gt;

</description>
      <category>security</category>
      <category>chrome</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A Web Page Can Tell Which Extensions You Have Installed. Here Is How.</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 15:47:33 +0000</pubDate>
      <link>https://dev.to/session_replay/a-web-page-can-tell-which-extensions-you-have-installed-here-is-how-553d</link>
      <guid>https://dev.to/session_replay/a-web-page-can-tell-which-extensions-you-have-installed-here-is-how-553d</guid>
      <description>&lt;p&gt;Open a page and it can start guessing which browser extensions you run before you click a thing. Not "extensions in general" - &lt;em&gt;which ones&lt;/em&gt;. Your password manager, your ad blocker, the wallet, the internal tool your employer ships, the accessibility extension you depend on. The page never asks and you never see it happen.&lt;/p&gt;

&lt;p&gt;This is not a bug in Chrome. It is the sum of a few features working exactly as designed, and the people best placed to close it are extension authors who mostly do not know they left it open. I maintain an extension and a library that talks to it, so I have spent real time on the detectable side of this. Here is how a page does it, what the answer is worth to whoever is asking, and what actually stops it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique one: ask the extension directly
&lt;/h2&gt;

&lt;p&gt;Some extensions accept messages from web pages on purpose - our own does, so a customer's "report a bug" button can tell whether the extension is there. The API is &lt;code&gt;chrome.runtime.sendMessage&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EXTENSION_ID&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ping&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;reply&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="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// it is installed, and it answered&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 a page to be &lt;em&gt;allowed&lt;/em&gt; to send that message, the extension has to list the page's origin in its manifest, under &lt;code&gt;externally_connectable&lt;/code&gt;. Authors who want their extension to work with any site reach for the wildcard:&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="nl"&gt;"externally_connectable"&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;"matches"&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;"&amp;lt;all_urls&amp;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;And that one line is the door. &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; does not mean "my customers' sites". It means every site on the internet may now open a channel to this extension - which means every site may ping it and learn whether you have it. The convenience the author wanted for their own pages, they handed to everybody's.&lt;/p&gt;

&lt;p&gt;This technique is narrow, because it only finds extensions that chose to talk to pages. The next one is not narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique two: knock on the extension's own files
&lt;/h2&gt;

&lt;p&gt;Extensions ship assets - icons, injected stylesheets, images. Any asset marked &lt;em&gt;web-accessible&lt;/em&gt; is reachable at a fixed URL built from the extension's id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chrome-extension://&amp;lt;extension-id&amp;gt;/icon-128.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixed. Which means a page does not have to be given permission to look. It just tries to load the file and watches what happens:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;img&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;Image&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="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&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="nf"&gt;resolve&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="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&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="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`chrome-extension://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;path&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onload&lt;/code&gt; fires, the extension is installed. &lt;code&gt;onerror&lt;/code&gt;, it is not. No message, no cooperation, nothing the extension author has to have agreed to beyond publishing a web-accessible file - which almost all of them do. Extension ids are public and stable; you read them off the store. So a page carries a list of ids and the resource path each one exposes, loops over it, and comes out the other side with an inventory. This is the workhorse, and it is old - security researchers demonstrated it at scale years ago and it still works today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique three: do not look for the extension, look for its fingerprints
&lt;/h2&gt;

&lt;p&gt;The two above need the extension's id. This one does not, and it catches the extensions that never talk to pages and ship no web-accessible files.&lt;/p&gt;

&lt;p&gt;An extension that changes a page leaves marks, and a page can bait them out. An ad blocker hides elements that look like ads, so plant one and see if it vanishes:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bait&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;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ad-banner ad-slot sponsored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cssText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;position:absolute;height:10px;left:-9999px&lt;/span&gt;&lt;span class="dl"&gt;'&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;requestAnimationFrame&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;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetHeight&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="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bait&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// blocked === true means something is hiding ad-like elements&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A password manager injects an icon into password fields, so put a hidden password input on the page and watch for the DOM to change around it. You do not learn &lt;em&gt;which&lt;/em&gt; ad blocker or &lt;em&gt;which&lt;/em&gt; password manager, but you learn the visitor runs one - and often that is the fact that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  So a page has a list of your extensions. What is that worth?
&lt;/h2&gt;

&lt;p&gt;This is the part that turns a curiosity into a reason to care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a near-unique fingerprint.&lt;/strong&gt; The particular set of extensions you run is close to an identifier, and unlike a cookie you cannot clear it. It rides along with every other fingerprint signal and sharpens all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It deanonymises you by inference.&lt;/strong&gt; This is the sharp end. Extensions are not neutral - many of them say something about the person:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an internal SSO or admin extension that only one company ships → the visitor works there&lt;/li&gt;
&lt;li&gt;a specific screen reader or accessibility extension → an accessibility need, which is about as sensitive as attributes get&lt;/li&gt;
&lt;li&gt;a particular crypto wallet → the visitor holds crypto, which is exactly what a phishing page wants to know &lt;em&gt;before&lt;/em&gt; it decides whether you are worth the effort&lt;/li&gt;
&lt;li&gt;a competitor's extension → this landing page can quietly greet their customer differently from everybody else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;It happens before you do anything.&lt;/strong&gt; Every technique above runs on page load. By the time you have read the headline, the page has tailored itself - or decided what you are - off a signal you did not know you were sending.&lt;/p&gt;

&lt;p&gt;None of this requires a breach or a trick. It is the platform behaving as documented, aimed at a question you were never asked whether you wanted answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually stops it
&lt;/h2&gt;

&lt;p&gt;The good news is that the fixes exist and most of them belong to the extension author, not to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For technique two, Manifest V3 gave authors the answer:&lt;/strong&gt; &lt;code&gt;use_dynamic_url&lt;/code&gt;.&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="nl"&gt;"web_accessible_resources"&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;"resources"&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;"icon-128.png"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matches"&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;"https://your-real-site.com/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"use_dynamic_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Two things there. &lt;code&gt;use_dynamic_url&lt;/code&gt; makes the resource's URL a per-session random token instead of the fixed path, so the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; probe has nothing stable to request. And &lt;code&gt;matches&lt;/code&gt; narrows &lt;em&gt;who&lt;/em&gt; may load the resource at all, instead of leaving it open to every origin. Ship a web- accessible file with neither and you are the reason technique two still works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For technique one, do not write &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;.&lt;/strong&gt; List the origins that genuinely need to talk to your extension. If that list cannot be known ahead of time - it is our exact case, a library any customer can install - then do not use &lt;code&gt;externally_connectable&lt;/code&gt; at all. We do detection the other way around: the extension injects a content script that listens for a &lt;code&gt;CustomEvent&lt;/code&gt; the page dispatches, and answers with another. A page that has not deliberately loaded our library and fired the event learns nothing, because the channel only carries a reply to a page that asked. The page can only find the extension by cooperating with it, which is the opposite of a silent probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For technique three there is no clean fix,&lt;/strong&gt; because the detection is of the extension's &lt;em&gt;effect&lt;/em&gt;, not the extension. Injecting into the page is the whole job. The best an author can do is be less trivially baitable - scope styles tightly, avoid marker classes and ids a page can guess. It is mitigation, not a cure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And as a person who just wants to be probed less:&lt;/strong&gt; the extensions you install are a fingerprint, so run fewer of them, and keep the ones that matter in a separate browser profile from the one you do sensitive things in. A profile with three extensions is a much larger crowd to hide in than one with thirty.&lt;/p&gt;




&lt;p&gt;The theme under all of it is the one that runs through most browser-privacy problems: a feature added for a good reason, used at its most convenient setting, adds up to a capability nobody decided to grant. &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; and a fixed resource URL are each perfectly reasonable in isolation. Together they mean the page you just opened knows things about you that you would not have told it.&lt;/p&gt;

&lt;p&gt;If you write extensions, the ten minutes it takes to set &lt;code&gt;use_dynamic_url&lt;/code&gt; and a real &lt;code&gt;matches&lt;/code&gt; list is the cheapest privacy win you will ship this year. Your users cannot do it for you, and they will never know you did.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
