<?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: Session Replay</title>
    <description>The latest articles on DEV Community by Session Replay (session_replay).</description>
    <link>https://dev.to/session_replay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14592%2F1d0ec0c8-ca04-4954-aefc-2cded833cb47.png</url>
      <title>DEV Community: Session Replay</title>
      <link>https://dev.to/session_replay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/session_replay"/>
    <language>en</language>
    <item>
      <title>Your Number Input Reads Empty, and the User Did Type Something</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:59:16 +0000</pubDate>
      <link>https://dev.to/session_replay/your-number-input-reads-empty-and-the-user-did-type-something-2fme</link>
      <guid>https://dev.to/session_replay/your-number-input-reads-empty-and-the-user-did-type-something-2fme</guid>
      <description>&lt;p&gt;The support ticket is short and sounds impossible. The form says the price is required. There is a price in the box. They can see it, they photographed it, and your validation still refuses to move on.&lt;/p&gt;

&lt;p&gt;Both halves are true. The box contains &lt;code&gt;1,5&lt;/code&gt; and your code is reading the empty string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that erases it
&lt;/h2&gt;

&lt;p&gt;The HTML Standard defines a value sanitization algorithm for &lt;code&gt;&amp;lt;input type="number"&amp;gt;&lt;/code&gt;, and it is one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the value of the element is not a valid floating-point number, then set it to the empty string instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "leave it alone". Not "keep the text and let the author decide". Set it to the empty string.&lt;/p&gt;

&lt;p&gt;And a valid floating-point number is defined precisely, as optional sign, digits, and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A single U+002E FULL STOP character (.)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A full stop. The grammar has no comma in it anywhere, in any locale, under any language setting. Roughly half the planet writes decimals with a comma, and this is one of the places where that is not a formatting preference but an invalid character.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually costs you
&lt;/h2&gt;

&lt;p&gt;Here is the bit worth the ten minutes, and it is not the comma.&lt;/p&gt;

&lt;p&gt;When a value fails that check, &lt;code&gt;input.value&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;. The same &lt;code&gt;""&lt;/code&gt; you get from a field nobody has touched. The two situations are now indistinguishable to every piece of code you own:&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;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;priceInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "You must enter a price."&lt;/span&gt;
  &lt;span class="c1"&gt;// Was the field blank, or did we just throw away what they typed?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your required-field check cannot tell them apart. Neither can your form analytics, which will record an abandoned field. Neither can the log line you add while debugging it, because you will log &lt;code&gt;value&lt;/code&gt; and see an empty string and believe the user.&lt;/p&gt;

&lt;p&gt;That is why this report arrives as a contradiction. The user says there is a number in the field. Your instrumentation says the field is empty. Nobody is lying and nobody can see the discrepancy from where they are standing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one field that knows
&lt;/h2&gt;

&lt;p&gt;The browser does keep the distinction. It is just somewhere nobody looks:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;priceInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;validity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;badInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// They typed something. It could not be converted.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;priceInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Genuinely empty.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MDN defines &lt;code&gt;badInput&lt;/code&gt; as indicating "if the user has provided input that the browser is unable to convert", and gives this exact case as its example: "if you have a number input element whose content is a string".&lt;/p&gt;

&lt;p&gt;Two branches instead of one, and the error message can stop being a lie. "Use a full stop for decimals" is a message somebody can act on. "This field is required" is a message that makes them doubt their own eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth knowing: the sibling behaves differently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;type="range"&lt;/code&gt; has the same kind of sanitization rule and a different ending. Where number sets the value to the empty string, range sets it to a default instead.&lt;/p&gt;

&lt;p&gt;So the same class of mistake is loud in one control and silent in the other. On a range, bad input quietly becomes a legitimate-looking number that the user never chose, and nothing anywhere reports a problem. Worth remembering if you are tempted to treat these two as variations on one input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you will never see it
&lt;/h2&gt;

&lt;p&gt;This failure is selected by the user's keyboard and locale rather than by their browser, their operating system or their network. It has no version number attached to it.&lt;/p&gt;

&lt;p&gt;If your machine and your test data produce a full stop, the field works perfectly for you forever. There is no flag to toggle, no device lab that surfaces it, and no field in any bug report template that asks which decimal separator somebody's culture uses. The closest you will get is noticing that the complaints cluster in one country, which takes a surprisingly long time when each one arrives individually and sounds like user error.&lt;/p&gt;

&lt;p&gt;The whole thing is recoverable from one boolean the browser was willing to tell you all along. It just never came up, because the value looked empty and empty looked like an answer.&lt;/p&gt;

</description>
      <category>forms</category>
      <category>html</category>
      <category>i18n</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Button Moved Before Your Finger Landed, and Your Click Handler Is Innocent</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:58:37 +0000</pubDate>
      <link>https://dev.to/session_replay/the-button-moved-before-your-finger-landed-and-your-click-handler-is-innocent-225b</link>
      <guid>https://dev.to/session_replay/the-button-moved-before-your-finger-landed-and-your-click-handler-is-innocent-225b</guid>
      <description>&lt;p&gt;The ticket says: "I pressed Cancel and it deleted my draft."&lt;/p&gt;

&lt;p&gt;You open the code. The Cancel handler cancels. The Delete handler deletes. Nothing is crossed, nothing is mislabelled, and the click that arrived went to Delete because Delete was genuinely under that finger at the moment it landed. Both of you are describing the same event correctly and neither description contains the cause.&lt;/p&gt;

&lt;p&gt;Something above those buttons changed size about eighty milliseconds earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser already has a defence, and you have probably never configured it
&lt;/h2&gt;

&lt;p&gt;There is a feature aimed squarely at this class of problem. MDN:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scroll anchoring behavior adjusts scroll position to minimize content shifts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the line that explains why you have never thought about it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scroll anchoring behavior is enabled by default in any browser that supports it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It works silently, so it is invisible until it is absent. Two things about it are worth holding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is one line to switch off.&lt;/strong&gt; &lt;code&gt;overflow-anchor: none&lt;/code&gt; opts an element out: in MDN's words, that element "won't be selected as a potential anchor". That is a reasonable line to write when you are fighting a different scroll bug, and a very easy line to leave behind. Nothing warns you that a protection is now off, because nothing announced it was on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Its job is the scroll position.&lt;/strong&gt; Read the definition again: it adjusts scroll position to minimise shifts. That keeps what somebody is reading where they are reading it. It is not, and does not claim to be, a promise that the element under a pointer at one instant is the same element 80ms later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement has a blind spot exactly where it hurts
&lt;/h2&gt;

&lt;p&gt;Here is the part that costs you something.&lt;/p&gt;

&lt;p&gt;Layout shifts are reported through &lt;code&gt;LayoutShift&lt;/code&gt; entries, and each one carries &lt;code&gt;hadRecentInput&lt;/code&gt;. MDN defines it precisely:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;returns &lt;code&gt;true&lt;/code&gt; if &lt;code&gt;lastInputTime&lt;/code&gt; is less than 500 milliseconds in the past&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Shifts flagged that way are normally dropped, and the reasoning is sound as far as it goes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Layout shifts are only a problem if the user is not expecting them, so layout shifts that are the result of user interactions (such as a user expanding a UI element) are often not considered in layout shift metrics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is correct for a user opening an accordion. But notice what the rule is made of: a 500 millisecond window bracketing user input. A mis-tap is, by definition, an event where a shift and an interaction happened within milliseconds of each other. The filter is not wrong, it is simply blind in the one window where this particular failure lives.&lt;/p&gt;

&lt;p&gt;So a dashboard can be green while this happens, and the green is not a lie. It is an answer to a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record the shifts you are told to ignore
&lt;/h2&gt;

&lt;p&gt;The fix is not to distrust the metric. It is to keep a second, unfiltered copy for debugging:&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;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Deliberately NOT filtering on hadRecentInput: those are the interesting ones here.&lt;/span&gt;
    &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;at&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;shift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;nearInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hadRecentInput&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&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;layout-shift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the last handful in memory and attach them to whatever you already send when something goes wrong. A click log that says &lt;code&gt;deleteDraft at 12840ms&lt;/code&gt; is ambiguous. The same log beside a shift of 0.03 at &lt;code&gt;12795ms&lt;/code&gt; is not ambiguous at all: something moved 45 milliseconds before the click, and you now have a timestamp to go and look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the report will never contain this
&lt;/h2&gt;

&lt;p&gt;Nobody writes "the layout moved as I was tapping". They do not perceive it as movement. They perceive it as having pressed the wrong thing, or as the application doing something they did not ask for, and the report is written in those terms because that is the honest account of their experience.&lt;/p&gt;

&lt;p&gt;The information that would settle it is the position of two elements during a window of about a tenth of a second, on their device, at their connection speed, with whatever loaded late that day. It is not in your logs, it is not in their description, and by the time anybody looks the page has settled into the layout you would have predicted.&lt;/p&gt;

&lt;p&gt;Which is the general shape: the evidence that decides this bug has a shorter half-life than the time it takes anybody to notice there is a bug.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>performance</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Error Tracker Shows Zero Crashes Because It Lives Inside the Thing That Crashed</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:59:03 +0000</pubDate>
      <link>https://dev.to/session_replay/your-error-tracker-shows-zero-crashes-because-it-lives-inside-the-thing-that-crashed-3neh</link>
      <guid>https://dev.to/session_replay/your-error-tracker-shows-zero-crashes-because-it-lives-inside-the-thing-that-crashed-3neh</guid>
      <description>&lt;p&gt;Open your error dashboard and filter for renderer crashes. You will find none. That is not because your pages do not crash.&lt;/p&gt;

&lt;p&gt;Chrome shipped 52 security fixes yesterday, four of them critical, and the classes read: memory corruption, use after free, improper input validation. Those do not throw an exception. They take the process down. The tab goes grey, or it reloads itself, or it simply stops being a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural problem, not a missing feature
&lt;/h2&gt;

&lt;p&gt;Everything you installed to tell you about a failure runs in the page. When the page is the casualty, so is the reporter. &lt;code&gt;window.onerror&lt;/code&gt; did not stay quiet; it was never called, because nothing was left running to call it.&lt;/p&gt;

&lt;p&gt;MDN says this outright about the browser's own crash report, which cannot be picked up by a &lt;code&gt;ReportingObserver&lt;/code&gt; for the plainest possible reason:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the reports are only generated when the browser crashes, at which point the observer code isn't available to run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So there is no listener for this. Not a &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt;, not an &lt;code&gt;unhandledrejection&lt;/code&gt; handler, not a &lt;code&gt;ReportingObserver&lt;/code&gt;. The event you most want is the one your page cannot be awake for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works is a header, not a script
&lt;/h2&gt;

&lt;p&gt;The only route is the browser reporting it afterwards, on its own, out of band. That is one response header:&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;Reporting-Endpoints: crash-reporting="https://example.com/reports"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No bundle change, nothing to initialise, nothing that can fail to load. That is the point: it has to work when your JavaScript does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that will make you think it is broken
&lt;/h2&gt;

&lt;p&gt;This is the part worth knowing before you wire it up, because each one looks like failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is slow on purpose.&lt;/strong&gt; Chrome's documentation: "Reports are delivered out-of-band from your site", and reports are "sent with a delay" of up to a minute so the browser can batch them. The browser "captures, queues, and sends them automatically at a suitable time". Crash a tab, watch your endpoint, see nothing, conclude it does not work: that is the obvious mistake and you will make it within the first five minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A non-secure endpoint is dropped in silence.&lt;/strong&gt; MDN: "Non-secure endpoints are ignored." No console warning, because there is no console to warn in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing in your code proves it is on.&lt;/strong&gt; It is a header, so it does not appear in your bundle, your dependency list or your init path. The only way to know it is live is to look at a response, which is also the only way to notice that a proxy or a CDN stripped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually arrives
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;POST&lt;/code&gt; with &lt;code&gt;Content-Type: application/reports+json&lt;/code&gt;. Each report carries &lt;code&gt;age&lt;/code&gt; in milliseconds since it was generated, plus &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;user_agent&lt;/code&gt;, and a &lt;code&gt;body&lt;/code&gt; whose shape depends on the type.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;age&lt;/code&gt; is the field that earns its place. The report arrives late by design, so the arrival time is not the incident time, and &lt;code&gt;age&lt;/code&gt; is what converts one into the other. Log the arrival and you will put the crash a minute after the thing that caused it.&lt;/p&gt;

&lt;p&gt;For a crash, the field to read is &lt;code&gt;reason&lt;/code&gt;. MDN documents two values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;oom&lt;/code&gt;: the page ran out of memory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unresponsive&lt;/code&gt;: the page was killed due to being unresponsive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why that one field is the whole diagnosis
&lt;/h2&gt;

&lt;p&gt;Those are different bugs with different fixes. One is a leak or an allocation you did not bound. The other is work on the main thread that never yielded.&lt;/p&gt;

&lt;p&gt;They are also the exact distinction the person in front of the problem cannot make for you. "It crashed" and "it froze so I closed it" are the same sentence to them, chosen by mood rather than by mechanism, and the report arrives with that choice already baked in. &lt;code&gt;reason&lt;/code&gt; is measured instead of typed, which is why it is worth more than the paragraph describing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing to do, and the thing to ask
&lt;/h2&gt;

&lt;p&gt;Set the header. It costs one line and it covers the case your in-page instrumentation is structurally unable to reach.&lt;/p&gt;

&lt;p&gt;Then, when somebody says a tab died, ask one question before anything else: did the whole tab go, or did the page stop responding first? You are asking them to separate &lt;code&gt;oom&lt;/code&gt; from &lt;code&gt;unresponsive&lt;/code&gt; without using either word, and it splits the problem in half before you have started looking.&lt;/p&gt;

&lt;p&gt;A zero on that dashboard was never a fact about your software. It was a fact about where you put the instrument.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>monitoring</category>
      <category>http</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Browser Zoom Changes the Size of a CSS Pixel, and Your Bug Report Cannot See It</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:03:59 +0000</pubDate>
      <link>https://dev.to/session_replay/browser-zoom-changes-the-size-of-a-css-pixel-and-your-bug-report-cannot-see-it-3d4k</link>
      <guid>https://dev.to/session_replay/browser-zoom-changes-the-size-of-a-css-pixel-and-your-bug-report-cannot-see-it-3d4k</guid>
      <description>&lt;p&gt;A report arrives with a screenshot. The sidebar has collapsed into the narrow layout, on a desktop, where it should never collapse. You ask for their screen size, because that is what the layout depends on. They say 1920 by 1080, which is true.&lt;/p&gt;

&lt;p&gt;You open the page at 1920 by 1080. The sidebar is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zoom is not magnification
&lt;/h2&gt;

&lt;p&gt;The model most of us carry is that browser zoom works like a magnifying glass held over a finished page: same layout, bigger pixels. It does not. It changes the unit the layout is written in.&lt;/p&gt;

&lt;p&gt;MDN, on &lt;code&gt;devicePixelRatio&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a page is zoomed in (made larger), the size of a CSS pixel increases, and so the &lt;code&gt;devicePixelRatio&lt;/code&gt; value increases.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The size of a CSS pixel increases, so everything measured in CSS pixels has fewer of them to work with. A window 1920 device pixels wide at 125% zoom hands your stylesheet a viewport of about 1536 CSS pixels, and your &lt;code&gt;(max-width: 1600px)&lt;/code&gt; rule is not misfiring when it collapses the sidebar. It is correct. The viewport genuinely is narrower than 1600 of the unit the rule is written in.&lt;/p&gt;

&lt;p&gt;So there is no bug in the layout and no error in the report. There are two different widths, and the only one either of you mentioned is the one that decides nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  They will not tell you, because they do not know
&lt;/h2&gt;

&lt;p&gt;This would be a harmless difference if zoom were something people did and then undid. From Chrome's own help:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The chosen zoom level will be saved for this specific site and won't affect other sites.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Saved, for your site specifically. Somebody held ctrl and turned a scroll wheel eight months ago, or zoomed in once to read a small label, and your site has rendered at 110% for them ever since. It survives a restart, which is why "I restarted the browser and it is still broken" is a perfectly consistent thing for them to report.&lt;/p&gt;

&lt;p&gt;It is also not a state they can see, so asking whether they are zoomed in gets an honest no.&lt;/p&gt;

&lt;p&gt;And it is worth remembering why many people are zoomed in: because the text is too small for them to read at 100%. "Press ctrl+0" is not a fix for that person. It is a request that they go back to using your site uncomfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number you would reach for cannot answer it
&lt;/h2&gt;

&lt;p&gt;Having learned that &lt;code&gt;devicePixelRatio&lt;/code&gt; moves with zoom, the obvious next step is to log it. It will not tell you what you want to know.&lt;/p&gt;

&lt;p&gt;MDN defines it as "the ratio of the resolution in physical pixels to the resolution in CSS pixels for the current display device". The display's own density and the page's zoom both change that ratio, so a single reading of 1.5 is either a high density screen at 100% or an ordinary one at 150%, and nothing in the number separates the two.&lt;/p&gt;

&lt;p&gt;The other scaling number does not close the gap. &lt;code&gt;visualViewport.scale&lt;/code&gt; returns "the pinch-zoom scaling factor applied to the visual viewport", and MDN is explicit that pinch-zooming "does not affect &lt;code&gt;devicePixelRatio&lt;/code&gt;, because this magnifies the page without changing the size of a CSS pixel". One number carries desktop zoom blended with display density. The other carries pinch-zoom alone. Neither of them reports a zoom level, and no standard property does.&lt;/p&gt;

&lt;p&gt;People do estimate it anyway, usually by comparing one window measurement against another. That is an inference built from two numbers that each move for their own reasons, and a bug report wants a reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record the decision, not the zoom
&lt;/h2&gt;

&lt;p&gt;The move that works is to stop trying to recover the zoom level and record what the layout actually decided:&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;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// the width the rules saw, in the unit the rules are written in&lt;/span&gt;
  &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;devicePixelRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pinch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visualViewport&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// the branches, recorded rather than reconstructed later&lt;/span&gt;
  &lt;span class="na"&gt;narrow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(max-width: 1600px)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;coarse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(pointer: coarse)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;matchMedia&lt;/code&gt; lines record the decision itself, rather than leaving you to work backwards to it from a width. And if you keep only one field, keep &lt;code&gt;innerWidth&lt;/code&gt;: a reporter who says 1920 and a page that says 1536 have told you, between them, everything you need to reproduce it, and neither of them had to understand why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling on asking
&lt;/h2&gt;

&lt;p&gt;Nearly every field in a bug report template is something the person can look up: browser, operating system, screen size, what they clicked. Zoom is an input to your layout that neither end can look up, because they do not know it and the page cannot read it back. The facts a template collects and the facts that decide a layout bug are not the same set, and the gap does not close by adding another question to the form.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>In a Background Tab, setInterval(fn, 1000) Runs Once a Minute</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:58:23 +0000</pubDate>
      <link>https://dev.to/session_replay/in-a-background-tab-setintervalfn-1000-runs-once-a-minute-4mo5</link>
      <guid>https://dev.to/session_replay/in-a-background-tab-setintervalfn-1000-runs-once-a-minute-4mo5</guid>
      <description>&lt;p&gt;Somebody leaves your dashboard open in a background tab, comes back after lunch, and the number on screen is twenty minutes stale. Or the countdown is wrong. Or they were never logged out when they should have been.&lt;/p&gt;

&lt;p&gt;Nothing threw. Nothing appeared in the console. Your timer simply stopped running at the rate you asked for, an hour ago, and never told anybody.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, in Chrome's own words
&lt;/h2&gt;

&lt;p&gt;Chrome applies what it calls &lt;strong&gt;intensive throttling&lt;/strong&gt; when all four of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"The page has been &lt;em&gt;hidden&lt;/em&gt; for more than 5 minutes."&lt;/li&gt;
&lt;li&gt;"The &lt;em&gt;chain count&lt;/em&gt; is 5 or greater."&lt;/li&gt;
&lt;li&gt;"The page has been silent for at least 30 seconds."&lt;/li&gt;
&lt;li&gt;"WebRTC is not in use."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the consequence, stated plainly: "the browser will check timers in this group once per &lt;strong&gt;minute&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;Once per minute. Not "a bit later than you asked". Not jitter. Your one-second interval becomes a sixty-second one, and the only thing that changed is that the user looked at a different tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your code is "chained" whether you meant it or not
&lt;/h2&gt;

&lt;p&gt;The chain count is the condition people assume will save them, and it will not. Chrome defines it like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you call &lt;code&gt;setTimeout&lt;/code&gt; in the same task as a &lt;code&gt;setTimeout&lt;/code&gt; callback, the second invocation is "chained". With &lt;code&gt;setInterval&lt;/code&gt;, each iteration is part of the &lt;em&gt;chain&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that second sentence carefully. &lt;strong&gt;Every iteration of a &lt;code&gt;setInterval&lt;/code&gt; adds to the chain&lt;/strong&gt;, so a plain &lt;code&gt;setInterval&lt;/code&gt; crosses the threshold of five on its fifth tick and stays there forever.&lt;/p&gt;

&lt;p&gt;And the other common shape is chained too: the poll that schedules the next poll at the end of its own callback. That is &lt;code&gt;setTimeout&lt;/code&gt; called in the same task as a &lt;code&gt;setTimeout&lt;/code&gt; callback, which is the definition.&lt;/p&gt;

&lt;p&gt;Between those two, most timer code anybody writes is chained by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug this actually causes
&lt;/h2&gt;

&lt;p&gt;The slowdown is the obvious half. The subtle half is that code which &lt;em&gt;counts ticks&lt;/em&gt; silently produces wrong answers, rather than slow ones:&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;// Wrong in a background tab: it is counting ticks, not time.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1000&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;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SESSION_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;expireSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After five minutes hidden, that runs once a minute, so &lt;code&gt;elapsed&lt;/code&gt; advances by one second per &lt;em&gt;minute&lt;/em&gt; of real time. A thirty-minute session limit now expires after thirty hours of wall clock. The user is not logged out, and your security control quietly stopped being one.&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;// Right: it reads the clock, so a late tick is still correct.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="nf"&gt;setInterval&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="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;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SESSION_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;expireSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both versions run once a minute when hidden. Only the second is still &lt;em&gt;right&lt;/em&gt; when it runs. That is the whole fix, and it is the general rule for anything timer-driven: never accumulate, always compare against the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you will never see it yourself
&lt;/h2&gt;

&lt;p&gt;Your tab is focused. A visible page gets minimal throttling, which is to say none at all, so the entire behaviour is invisible from the seat you develop in.&lt;/p&gt;

&lt;p&gt;Two of the four conditions make it worse, because they create people who genuinely cannot reproduce it. A page that "has made noises in the past 30 seconds" is exempt, and so is one using WebRTC. So a video-call app is fine while a dashboard is not, and the colleague who happens to have a sound playing in that tab will tell you it works for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reaches you instead
&lt;/h2&gt;

&lt;p&gt;The report says the timer was wrong, or the page was stale, or it did not log them out. All three are true, none of them is reproducible on your machine, and the fact that would explain it, that the tab sat in the background for more than five minutes, is not something anybody thinks to mention. It is not a detail about your software. It is a detail about what else they had open.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>async</category>
      <category>browsers</category>
    </item>
    <item>
      <title>Your Autoplay Works Because Chrome Has Learned to Trust You</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 12:58:29 +0000</pubDate>
      <link>https://dev.to/session_replay/your-autoplay-works-because-chrome-has-learned-to-trust-you-1dof</link>
      <guid>https://dev.to/session_replay/your-autoplay-works-because-chrome-has-learned-to-trust-you-1dof</guid>
      <description>&lt;p&gt;Your video autoplays every single time you load the page. It does not autoplay for somebody arriving for the first time, and there is no bug in your code.&lt;/p&gt;

&lt;p&gt;Chrome has been keeping a score on you, per site, and you have been raising it all week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The score is real and you can read it
&lt;/h2&gt;

&lt;p&gt;Chrome's autoplay documentation states the rule plainly. Autoplay with sound is allowed if "The user has interacted with the domain (click, tap, etc.)" or, on desktop, if "the user's Media Engagement Index threshold has been crossed, meaning the user has previously played video with sound."&lt;/p&gt;

&lt;p&gt;The Media Engagement Index "measures an individual's propensity to consume media on a site", and Chrome calculates it as "a ratio of visits to significant media playback events per origin."&lt;/p&gt;

&lt;p&gt;A playback counts as significant only when all of these hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Consumption of the media (audio/video) must be greater than seven seconds."&lt;/li&gt;
&lt;li&gt;"Audio must be present and unmuted."&lt;/li&gt;
&lt;li&gt;"The tab with the video is active."&lt;/li&gt;
&lt;li&gt;"Size of the video (in px) must be greater than 200x140."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read that list again as a description of what you do all day when you are working on a video feature. You load the page, you unmute it to check the sound, you watch more than seven seconds of it to see whether it stutters, you do it in the tab you are looking at, at a normal size.&lt;/p&gt;

&lt;p&gt;Every one of those is a significant playback event for your own origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which makes this worse than ordinary unrepresentativeness
&lt;/h2&gt;

&lt;p&gt;You are not merely an unrepresentative tester for this feature. You are becoming &lt;em&gt;less&lt;/em&gt; representative with every test. The more carefully you check that autoplay works, the more confident Chrome becomes that you personally like watching video on this domain, and the more reliably it will let the video play for you and nobody else.&lt;/p&gt;

&lt;p&gt;The useful part is that none of this is hidden. Open &lt;code&gt;about://media-engagement&lt;/code&gt; and Chrome lists the score it is keeping, per origin, for your profile. Find your own site in that list. If the score is high, you have not actually tested autoplay in months, whatever your last few page loads suggested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meanwhile the failure is silent by default
&lt;/h2&gt;

&lt;p&gt;Here is the code almost everybody writes:&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;// Looks fine. Fails silently for a first-time visitor.&lt;/span&gt;
&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome is explicit about what happens next: "calls to play() without a user gesture will reject the promise with a NotAllowedError DOMException." And it gives the instruction most code ignores: "You should always look at the Promise returned by the play function to see if it was rejected."&lt;/p&gt;

&lt;p&gt;A bare &lt;code&gt;videoEl.play()&lt;/code&gt; throws that rejection away. Nothing appears in the console, nothing reaches your error tracker, and the video simply sits there showing its first frame. From the user's side, the page is broken in a way they have no words for.&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;videoEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NotAllowedError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;
  &lt;span class="c1"&gt;// Chrome declined. Fall back to something that is always permitted.&lt;/span&gt;
  &lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;showUnmuteButton&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;That fallback works because of one more line in the same documentation: "Muted autoplay is always allowed." Start muted, give the user a control, and the experience stops depending on what the browser has decided about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the report will not say any of this
&lt;/h2&gt;

&lt;p&gt;The bug report says the video does not play. It cannot say more than that, because nothing told the user anything: no error dialog, no console message they would ever see, no indication that a decision was made about them at all.&lt;/p&gt;

&lt;p&gt;And you cannot reproduce it, by construction. Your profile has a high engagement score for your own origin and theirs has none. A fresh profile or a guest window will show you the failure in ten seconds; your ordinary browser, the one you have been working in, never will. That is the whole gap, and it widens every day you spend on the feature.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>html</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Your Tab Was Discarded, and Nothing in Your Code Was Told</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:59:06 +0000</pubDate>
      <link>https://dev.to/session_replay/your-tab-was-discarded-and-nothing-in-your-code-was-told-1pm</link>
      <guid>https://dev.to/session_replay/your-tab-was-discarded-and-nothing-in-your-code-was-told-1pm</guid>
      <description>&lt;p&gt;A user leaves a tab open overnight with a half-filled form in it. In the morning they click back to it and the form is empty. The tab looked exactly as they left it: right title, right favicon, sitting where it always sat.&lt;/p&gt;

&lt;p&gt;They are not misremembering, and nothing on your page failed. Chrome threw the page away and told nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Chrome's own description of Memory Saver: "When Memory Saver mode is enabled, Chrome will proactively discard tabs that have been unused in the background for some time." And then, "If the user revisits that tab, the page will be reloaded automatically."&lt;/p&gt;

&lt;p&gt;The sentence that explains why the user cannot tell you any of this is the next one: "When a tab is discarded, its title and favicon still appear in the tab strip but the page itself is gone."&lt;/p&gt;

&lt;p&gt;So there is no cue. A discarded tab is visually identical to a live one. The person clicking it has no reason to think anything happened between last night and this morning, because from where they sit, nothing did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing in your code noticed either
&lt;/h2&gt;

&lt;p&gt;This is the part worth knowing, because the instinct is to reach for a save-on-exit handler and it will not help.&lt;/p&gt;

&lt;p&gt;The page lifecycle documentation defines the state plainly: "A page is in the &lt;em&gt;discarded&lt;/em&gt; state when it is unloaded by the browser in order to conserve resources." Entering it fires &lt;strong&gt;no events&lt;/strong&gt;, and once there, "No tasks, event callbacks, or JavaScript of any kind can run in this state."&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;beforeunload&lt;/code&gt;. Not &lt;code&gt;pagehide&lt;/code&gt;. Not &lt;code&gt;unload&lt;/code&gt;. Your handler does not run late, it does not run at all, because there is no unload from the page's point of view. There is a page, and then there is no page.&lt;/p&gt;

&lt;p&gt;The last callback you get is &lt;code&gt;freeze&lt;/code&gt;, which fires when the tab goes from hidden to frozen, and even that comes with a caveat: "freezable tasks in the page's task queues are suspended until the page is unfrozen". For a tab that is subsequently discarded, unfrozen never arrives, so anything you queued at freeze time and did not finish synchronously is gone with everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs you, which is more than one empty form
&lt;/h2&gt;

&lt;p&gt;Measure this and it does not look like a browser behaviour at all. A discard followed by a return is a fresh page load, so in your analytics one person becomes two sessions, with an abandoned form in the first and a cold start in the second. The funnel reports a bounce and a new arrival. The truth is one user who never left.&lt;/p&gt;

&lt;p&gt;That is the part that quietly skews numbers nobody is auditing: not a lost form, but a session count that includes an event the user did not cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one fact that settles it
&lt;/h2&gt;

&lt;p&gt;There is a flag for exactly this. Chrome again: "It is possible to detect that a tab was discarded after a user returns to it and the page is reloaded. In these situations the &lt;code&gt;document.wasDiscarded&lt;/code&gt; property will be true."&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;// On load: was this page here before, and thrown away?&lt;/span&gt;
&lt;span class="k"&gt;if &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;wasDiscarded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;restoreDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page_load&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tab_discard&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;// Last chance to write anything down.&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;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;freeze&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;currentDraft&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;capture&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sessionStorage&lt;/code&gt; is the right place, and the reason is worth a sentence. A discard destroys the page, not the tab: the tab keeps its slot, its title and its favicon, and &lt;code&gt;sessionStorage&lt;/code&gt; is scoped to that tab rather than to the document in it. So it is still there on the other side. This is the one case where the per-tab store outlives the thing you would expect to outlive it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this reaches you as an unreproducible report
&lt;/h2&gt;

&lt;p&gt;Your own tab is focused. Focused tabs do not get discarded, so you will not see this while you are working on the page, and you will not see it in the ten minutes you spend trying to repeat it.&lt;/p&gt;

&lt;p&gt;Meanwhile the report says "I came back and my work was gone", which sounds like a bug in your form. There is no error, no console output, nothing in your error tracker, and nothing the reporter can tell you, because the browser did not tell them either. The single piece of evidence that separates "your save is broken" from "the browser discarded the tab" is a boolean that was true for one moment in somebody else's browser, and unless something was reading it then, it is not recoverable afterwards.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>chrome</category>
      <category>performance</category>
    </item>
    <item>
      <title>`localStorage.clear()` Tells Your Other Tabs Nothing They Are Listening For</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:01:54 +0000</pubDate>
      <link>https://dev.to/session_replay/localstorageclear-tells-your-other-tabs-nothing-they-are-listening-for-499l</link>
      <guid>https://dev.to/session_replay/localstorageclear-tells-your-other-tabs-nothing-they-are-listening-for-499l</guid>
      <description>&lt;p&gt;A user clicks log out, switches to their other tab, and is still signed in. You cannot reproduce it, because you test with one tab open, and with one tab open it works perfectly.&lt;/p&gt;

&lt;p&gt;Two pieces of browser behaviour meet to produce this, and each is reasonable on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The event you are listening for deliberately skips you
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;storage&lt;/code&gt; event is how two tabs on the same origin find out about each other. MDN is exact about who receives it: it "fires when another document that shares the same storage area (either &lt;code&gt;localStorage&lt;/code&gt; or &lt;code&gt;sessionStorage&lt;/code&gt;) as the current window updates that storage area", and then flatly, "The event is &lt;em&gt;not&lt;/em&gt; fired on the window that made the change."&lt;/p&gt;

&lt;p&gt;That is the right design, and it is usually invisible. The tab where somebody clicked log out does not need an event, because it already knows. So the handler gets written, tested in a single tab, and the local half works. Whatever is broken about the cross-tab half has no symptom on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The event the other tabs get has no key on it
&lt;/h2&gt;

&lt;p&gt;This is the part that costs the afternoon. Most handlers are keyed on the thing they care about:&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;storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth.token&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newValue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is correct if your logout path calls &lt;code&gt;localStorage.removeItem('auth.token')&lt;/code&gt;. Plenty of logout paths call &lt;code&gt;localStorage.clear()&lt;/code&gt; instead, which is the more cautious instinct when a session ends: take everything, leave nothing behind.&lt;/p&gt;

&lt;p&gt;The HTML specification says exactly what &lt;code&gt;clear()&lt;/code&gt; broadcasts. Its step is to "Broadcast this with null, null, and null." One event, not one per key, and &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;oldValue&lt;/code&gt; and &lt;code&gt;newValue&lt;/code&gt; are all &lt;code&gt;null&lt;/code&gt;. There is nothing in it that names what was removed.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;e.key === 'auth.token'&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. The handler returns. The other tab keeps running with the token it read into memory at load, and keeps making authenticated requests with it until something forces a reload. Nothing throws. Nothing reaches your error tracker. The tab is just still logged in, which is the one outcome the feature existed to prevent.&lt;/p&gt;

&lt;p&gt;Treating a null key as "all of it went" is the whole fix:&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;storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenRemoved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth.token&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newValue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&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;cleared&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;tokenRemoved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing even if you never call &lt;code&gt;clear()&lt;/code&gt; yourself: a third-party script on your page can, and your handler will not notice that either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody reports this usefully
&lt;/h2&gt;

&lt;p&gt;The reporter's description is of one tab. The state that explains the bug is in the other one, and they closed it before writing to you, because it did not look relevant. It was open, it was signed in, and that was the normal state of their afternoon.&lt;/p&gt;

&lt;p&gt;So you get "logging out does not work", which is false in the sense that matters to you, since it plainly works. Everything a reporter can tell you about the tab in front of them is consistent with the feature being fine. Nothing about the way the problem presents itself suggests that the number of tabs is the variable, which is why the answer usually arrives by accident, weeks later, from somebody who happened to have two windows open while watching a network panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  sessionStorage is not the way out
&lt;/h2&gt;

&lt;p&gt;The obvious reaction is to put the token somewhere per-tab instead. That does solve the cross-tab disagreement, and it introduces a subtler question about where a new tab's session comes from.&lt;/p&gt;

&lt;p&gt;MDN: "If the page has an &lt;code&gt;opener&lt;/code&gt;, the &lt;code&gt;sessionStorage&lt;/code&gt; is initially a copy of the opener's &lt;code&gt;sessionStorage&lt;/code&gt; object. However, they are still separate and changes to one do not affect the other." The specification's version is that after creating a new auxiliary browsing context, "the session storage is copied over".&lt;/p&gt;

&lt;p&gt;Whether a new tab starts with a copy of the old one's data therefore depends on how it was opened. &lt;code&gt;window.open(url)&lt;/code&gt; with default features sets an opener, so the copy happens. A plain &lt;code&gt;&amp;lt;a target="_blank"&amp;gt;&lt;/code&gt; does not, because &lt;code&gt;target="_blank"&lt;/code&gt; now "implicitly provides the same &lt;code&gt;rel&lt;/code&gt; behavior as setting &lt;code&gt;rel="noopener"&lt;/code&gt;", and with no opener there is no auxiliary context to copy from. Two links that look identical in the markup, two different starting states.&lt;/p&gt;

&lt;p&gt;None of this is obscure, and all of it is invisible from a single tab. That is the part worth carrying: the variable that explains the bug was never in the report, because the reporter had no reason to think a second tab was part of the story.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>browsers</category>
    </item>
    <item>
      <title>Every Origin Your Page Talks To, and the Four Traps in Listing Them</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:58:16 +0000</pubDate>
      <link>https://dev.to/session_replay/every-origin-your-page-talks-to-and-the-four-traps-in-listing-them-4n5m</link>
      <guid>https://dev.to/session_replay/every-origin-your-page-talks-to-and-the-four-traps-in-listing-them-4n5m</guid>
      <description>&lt;p&gt;Safari 27 now blocks a category of third-party domain outright, at the network level, on by default. Whatever you think of that, it leaves every team with the same question: do we have one of those on our pages?&lt;/p&gt;

&lt;p&gt;Most teams cannot answer it, and not because they are careless. These things arrive through a tag manager or a marketing vendor, which means the thing that added them is configuration rather than code. Reading your repository will not tell you, and the answer can change without a deploy.&lt;/p&gt;

&lt;p&gt;So here is how to actually get the list, and the four things that quietly stop you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap version, and what it misses
&lt;/h2&gt;

&lt;p&gt;The network panel answers it for one page load, on your machine, on the path you happened to take, with your extensions and your cookie state. That is genuinely useful and it is not an audit. The request that only fires for logged-in users on the checkout page is not in it.&lt;/p&gt;

&lt;p&gt;A better local version is Resource Timing, because it enumerates what the page actually fetched rather than what you watched it fetch:&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;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MDN is explicit that this covers &lt;code&gt;XMLHttpRequest&lt;/code&gt;, images and scripts alike, and that cross-origin resources do appear in the list. Many of their timing fields are restricted to &lt;code&gt;0&lt;/code&gt; without a &lt;code&gt;Timing-Allow-Origin&lt;/code&gt; header, but you are reading &lt;code&gt;name&lt;/code&gt;, not timings, so that restriction does not cost you anything here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap one: the buffer is small.&lt;/strong&gt; MDN: "By default only 250 resource timing entries are buffered." A heavy page passes that without trying, and the entries you lose are silent. If you want all of them, observe instead of sampling:&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;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&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;resource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The version that covers pages you never visit
&lt;/h2&gt;

&lt;p&gt;Everything above runs where you are standing, which is the thing that makes the original problem hard. The answer that covers every page and every real visitor is a response header, and it blocks nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; "helps to monitor Content Security Policy (CSP) violations and their effects without enforcing the security policies", in MDN's words. So you can declare a policy you know is wrong, deliberately, and collect what breaks it without breaking anything.&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;Content-Security-Policy-Report-Only: connect-src 'self'; report-to csp-endpoint
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That says connections should only go to your own origin, which is false, and that is the point. Every outbound connection to anywhere else is reported to you, from production, from real browsers, on pages you have never opened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap two, and it is the one that wastes an afternoon.&lt;/strong&gt; From the same page: "The CSP &lt;code&gt;report-to&lt;/code&gt; directive must be specified for reports to be sent: if not, the operation won't have any effect." A report-only header with no reporting destination is not a quiet success. It does nothing at all, and it looks exactly like a policy nothing is violating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap three: &lt;code&gt;report-to&lt;/code&gt; is the modern directive and is not universally supported.&lt;/strong&gt; MDN declares both in its own example, noting that &lt;code&gt;report-to&lt;/code&gt; is preferred over the deprecated &lt;code&gt;report-uri&lt;/code&gt; "but we declare both because &lt;code&gt;report-to&lt;/code&gt; does not yet have full cross-browser support". If you only set the modern one you will collect reports from some browsers and conclude the others are clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap four: you cannot do this from a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag.&lt;/strong&gt; MDN states that this header "is not supported inside a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; element". If your deployment makes response headers awkward, the workaround you were about to reach for is silently ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the list is worth having before you need it
&lt;/h2&gt;

&lt;p&gt;Any one of these origins can stop answering for reasons that never reach you: a browser default, an extension, an endpoint security product, a corporate proxy, a network that resolves it differently.&lt;/p&gt;

&lt;p&gt;When that happens, the request is refused in somebody else's browser. Your server sees nothing, because there is nothing to see. Your logs are complete and useless. What arrives is a person saying that signing up did not work, with no mention of a third party, because they cannot see one either.&lt;/p&gt;

&lt;p&gt;Having the list already is the difference between that being a mystery and being a short conversation. It takes an afternoon once, and the alternative is learning which third parties you depend on at the moment one of them disappears.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Your await Can Spend the User's Gesture Before You Use It</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Mon, 19 Oct 2026 12:58:25 +0000</pubDate>
      <link>https://dev.to/session_replay/your-await-can-spend-the-users-gesture-before-you-use-it-438h</link>
      <guid>https://dev.to/session_replay/your-await-can-spend-the-users-gesture-before-you-use-it-438h</guid>
      <description>&lt;p&gt;The copy button works. You have clicked it forty times. A user says nothing happens when they click it, and sends a screenshot of a button, which is not helpful because the button looks exactly the way it looks when it works.&lt;/p&gt;

&lt;p&gt;There is a good chance the difference between you and them is not the browser, the extension or the operating system. It is how long their network took in the middle of your click handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule you are up against
&lt;/h2&gt;

&lt;p&gt;A set of browser APIs will only run if the user has recently and deliberately interacted with the page. MDN calls that state transient activation, "a window state that indicates a user has recently directly and meaningfully interacted with the window".&lt;/p&gt;

&lt;p&gt;Two properties of it are the whole story.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;expires&lt;/strong&gt;. MDN says transient activation "expires after a timeout (if not renewed by further interaction)", and pointedly does not say how long that timeout is. It is not a number you can code against.&lt;/p&gt;

&lt;p&gt;It can be &lt;strong&gt;consumed&lt;/strong&gt;. The same page says it "may also be consumed/deactivated after using some gated features", with &lt;code&gt;Window.open()&lt;/code&gt; as the example. Using one gated feature can leave you without activation for the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that fails
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;button&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;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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;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/share-link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the gesture is burning here&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&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;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&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;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// may now be refused&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is ordinary, readable code, and it works perfectly on your machine, because &lt;code&gt;fetch&lt;/code&gt; against a local API comes back in single-digit milliseconds and the activation is still alive when the clipboard call runs.&lt;/p&gt;

&lt;p&gt;On a real connection that same round trip is a few hundred milliseconds. On hotel wifi or a train it is seconds. The code did not change. The gesture ran out.&lt;/p&gt;

&lt;p&gt;So the failure is a function of &lt;strong&gt;latency&lt;/strong&gt;, which is why the person who wrote it never sees it, and why "works for me" is true rather than careless.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is a cluster, not one bug
&lt;/h2&gt;

&lt;p&gt;Clipboard is the one people meet first, but MDN's list of features gated by transient activation is long and the same pattern breaks all of them. &lt;code&gt;Element.requestFullScreen()&lt;/code&gt;. &lt;code&gt;Window.open()&lt;/code&gt;. &lt;code&gt;Navigator.share()&lt;/code&gt;. &lt;code&gt;MediaDevices.getDisplayMedia()&lt;/code&gt;. &lt;code&gt;HTMLInputElement.showPicker()&lt;/code&gt;. The file pickers, &lt;code&gt;showOpenFilePicker()&lt;/code&gt; and friends.&lt;/p&gt;

&lt;p&gt;If you have ever been told "the browser blocks popups", this is usually what was actually happening. The browser did not object to the popup. It objected to a popup opened with no live gesture behind it, which is a different rule with a different fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;beforeunload&lt;/code&gt; is not like this
&lt;/h2&gt;

&lt;p&gt;Worth knowing, because it is the thing that makes people think activation is simpler than it is.&lt;/p&gt;

&lt;p&gt;MDN separates transient from &lt;strong&gt;sticky&lt;/strong&gt; activation: "transient activation only lasts for a short while, and may in some cases be consumed (deactivated) when a protected feature is used, while sticky activation persists until the end of the session". The &lt;code&gt;beforeunload&lt;/code&gt; event, autoplay and &lt;code&gt;Navigator.vibrate()&lt;/code&gt; are on the sticky list.&lt;/p&gt;

&lt;p&gt;So your unsaved-changes dialog fires reliably after any interaction at all, and your clipboard call does not, and the inconsistency is specified rather than accidental.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;Call the gated API &lt;strong&gt;first&lt;/strong&gt;, with whatever you already have, and do the slow part after.&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;button&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;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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;await&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;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildUrlLocally&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;  &lt;span class="c1"&gt;// gesture still live&lt;/span&gt;
  &lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;copied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                         &lt;span class="c1"&gt;// the slow part, after&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where the value genuinely has to come from the server, the fix is to stop the gesture and the request from being in the same sequence: fetch it when the component mounts, or on hover, or on &lt;code&gt;pointerdown&lt;/code&gt; rather than &lt;code&gt;click&lt;/code&gt;, so that by the time the user commits, there is nothing left to wait for.&lt;/p&gt;

&lt;p&gt;And do not let the call fail silently. All of these reject rather than returning a status, so a bare &lt;code&gt;await&lt;/code&gt; with no &lt;code&gt;catch&lt;/code&gt; produces exactly the symptom the ticket describes: nothing happened, no error on screen, nothing in your logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it expensive
&lt;/h2&gt;

&lt;p&gt;The fact that decides whether this works is how long one request took on somebody else's connection at the moment they clicked.&lt;/p&gt;

&lt;p&gt;That number is not in the bug report. It is not in your server logs either, because the request you would want to time is the one that succeeded, and the thing that failed afterwards never reached you at all. The reporter cannot supply it, because nothing they can see distinguishes a slow success from a refusal.&lt;/p&gt;

&lt;p&gt;Which is the ordinary shape of the reports that take longest: not difficult once you know, and invisible from where you are standing.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>browsers</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Two Date Strings One Character Apart Land on Different Days</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Fri, 16 Oct 2026 12:58:48 +0000</pubDate>
      <link>https://dev.to/session_replay/two-date-strings-one-character-apart-land-on-different-days-12oi</link>
      <guid>https://dev.to/session_replay/two-date-strings-one-character-apart-land-on-different-days-12oi</guid>
      <description>&lt;p&gt;A support ticket says the delivery date is showing a day early. You open the page. The date is correct. You check the database. Correct. You ask them to clear their cache, which does nothing, because nothing about this is cached.&lt;/p&gt;

&lt;p&gt;They are right and you are right, and the thing standing between you is a rule in the language about when a date string means UTC and when it means wherever the reader happens to be standing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;A date-only string is parsed as UTC. The same string with a time attached is parsed as local.&lt;/p&gt;

&lt;p&gt;That is not a quirk of one engine. It is what the specification asks for, and MDN states the two halves plainly. Of &lt;code&gt;"2019-01-01"&lt;/code&gt; it says the string "will imply UTC time because it's date-only". Of &lt;code&gt;"2019-01-01T00:00:00"&lt;/code&gt; it says the call "will be set to 2019-01-01 at 00:00:00 in the local timezone of the system, because it has both date and time".&lt;/p&gt;

&lt;p&gt;So these two are not the same instant:&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;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-03-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// midnight UTC&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-03-01T00:00:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// midnight where the reader is&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste both into a console and look at the gap. If you are on UTC the gap is zero, which is most of why this survives code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the day goes
&lt;/h2&gt;

&lt;p&gt;Midnight is the worst possible instant to be ambiguous about, because it is the boundary. Any negative offset moves a UTC midnight backwards across it.&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;d&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-03-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// UTC&lt;/span&gt;
&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// the first of March, or the last day of February&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which one you get depends entirely on where the browser is. In UTC-5 that date renders as 28 February. The value never changed, the database is fine, your API response is fine, and the pixel on the screen says the wrong day for every visitor west of the meridian.&lt;/p&gt;

&lt;p&gt;East of it the mirror case is waiting with any time late enough in the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it reaches production
&lt;/h2&gt;

&lt;p&gt;Three things have to line up, and they usually do.&lt;/p&gt;

&lt;p&gt;The team is in one timezone, often a European one where the offset is small and sometimes zero, so the bug is invisible in development. The test suite runs in CI with &lt;code&gt;TZ&lt;/code&gt; unset or set to UTC, where the gap is exactly zero and the assertion passes forever. And the string that triggers it is produced a long way from the code that renders it, usually by a backend serialising a date column, so nobody reading either file sees both halves at once.&lt;/p&gt;

&lt;p&gt;Then somebody adds or removes a time component for an unrelated reason, a formatter changes, an API starts returning a full timestamp instead of a date, and the interpretation silently flips for users you cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;Do not hand an ambiguous string to the constructor and hope. Say which one you mean.&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;// Explicitly UTC, from parts&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Explicitly a fixed instant, with an offset in the string&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-03-01T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Rendering a calendar date, where no instant is involved at all&lt;/span&gt;
&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UTC&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;That last one matters more than it looks. A delivery date, a birthday and an invoice date are not instants. They are labels on a calendar, and converting them to a point on a timeline is the whole mistake. If you never want the reader's clock involved, say so at the formatting end too.&lt;/p&gt;

&lt;p&gt;And treat any format outside the spec's as a coin toss. MDN's own note is that the section describing them "contains implementation-specific behavior that may be inconsistent across browsers or different versions of browsers", and the unreliability of &lt;code&gt;Date.parse()&lt;/code&gt; is one of the stated motivations for Temporal existing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it expensive
&lt;/h2&gt;

&lt;p&gt;The bug is deterministic. Given the string and the offset, it happens every single time.&lt;/p&gt;

&lt;p&gt;But it is deterministic in a variable you do not have. The one fact that decides whether your reader sees the first of March or the twenty-eighth of February is their UTC offset at the moment they loaded the page, and that is not in the ticket, not in your logs, and not something they know to tell you. They will say the date is wrong, which is true, and you will fail to reproduce it, which is also true.&lt;/p&gt;

&lt;p&gt;It is the ordinary shape of the reports that take longest: not hard to understand once you see it, and impossible to see from where you are standing.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>debugging</category>
    </item>
    <item>
      <title>"I Can't Reach the Button" Is Unreproducible on Your Desktop, and `100vh` Is Why</title>
      <dc:creator>Vladimir Elchinov</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:59:43 +0000</pubDate>
      <link>https://dev.to/session_replay/i-cant-reach-the-button-is-unreproducible-on-your-desktop-and-100vh-is-why-pm3</link>
      <guid>https://dev.to/session_replay/i-cant-reach-the-button-is-unreproducible-on-your-desktop-and-100vh-is-why-pm3</guid>
      <description>&lt;p&gt;A report comes in: the submit button at the bottom of a full-height section cannot be tapped. You open the page on your desktop. Fine. You open the responsive emulator at the phone's exact dimensions. Also fine. You close the ticket.&lt;/p&gt;

&lt;p&gt;The reporter was right, and so were you. The difference is a browser interface neither of you was looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;vh&lt;/code&gt; is not the height of the screen
&lt;/h2&gt;

&lt;p&gt;It is the height of the screen with the URL bar taken away. Not as a bug, and not as an implementation detail somebody will fix. It is what the unit is specified to mean.&lt;/p&gt;

&lt;p&gt;CSS Values and Units Level 4 defines three viewport sizes. The small viewport is "the viewport sized assuming any UA interfaces that are dynamically expanded and retracted to be expanded". The large viewport is the same sentence with the last word changed to "retracted". And then the part that catches people:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The large viewport-percentage units (lv*) and default viewport-percentage units (v*) are defined with respect to the large viewport size.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The default units. &lt;code&gt;vh&lt;/code&gt; is &lt;code&gt;lvh&lt;/code&gt;. Your &lt;code&gt;100vh&lt;/code&gt; section is laid out as though the URL bar is not there, on a device where it is there until the user scrolls.&lt;/p&gt;

&lt;p&gt;So on first paint, before anybody has scrolled, the bottom of that section is underneath the browser chrome. The button is on the page, it is rendered, it has non-zero dimensions, and your tests will say so. It is just not somewhere a thumb can go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it was specified that way
&lt;/h2&gt;

&lt;p&gt;Not an oversight. The alternative is worse.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;vh&lt;/code&gt; tracked the bar as it retracted, every full-height element would reflow mid-scroll. Headers would jump, a sticky footer would walk up the screen, and text would reparagraph under the reader's eyes while they were reading it. Pinning &lt;code&gt;vh&lt;/code&gt; to the larger of the two sizes buys you a unit that holds still. The cost is that it is wrong by the height of the bar at exactly the moment most people see your page for the first time.&lt;/p&gt;

&lt;p&gt;You can see all three at once:&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;probe&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;probe&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;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absolute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// out of flow, so measuring cannot move the page&lt;/span&gt;
&lt;span class="nx"&gt;probe&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;visibility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&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;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lvh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dvh&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;// An unsupported unit is an invalid declaration, which leaves the previous&lt;/span&gt;
  &lt;span class="c1"&gt;// value in place and would print the last unit's number as if it were this one.&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;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;height&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`100&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unit&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not supported here&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;probe&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;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`100&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unit&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;innerHeight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visualViewport&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visualViewport&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a desktop browser every number is the same, which is the entire reason this never shows up while you are working. On a phone, before you scroll, &lt;code&gt;svh&lt;/code&gt; and &lt;code&gt;lvh&lt;/code&gt; differ by the height of the bar and &lt;code&gt;vh&lt;/code&gt; sits with &lt;code&gt;lvh&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix you reach for is the one the spec warns about
&lt;/h2&gt;

&lt;p&gt;Having found &lt;code&gt;dvh&lt;/code&gt;, the obvious move is to swap it in everywhere. The specification gets ahead of you:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The sizes of the dynamic viewport-percentage units are not stable even while the viewport itself is unchanged. Using these units can cause content to resize e.g. while the user scrolls the page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the reflow problem you just opted back into. &lt;code&gt;dvh&lt;/code&gt; is right where a resize mid-scroll is genuinely harmless. For anything that has to be &lt;em&gt;reachable without scrolling&lt;/em&gt; - a primary action, a dismiss control, a cookie banner's accept button - &lt;code&gt;svh&lt;/code&gt; is the honest unit, because it is the one that assumes the chrome is in the way. The layout is slightly short when the bar retracts. Nobody has ever filed a ticket about slightly short.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the report could not have told you
&lt;/h2&gt;

&lt;p&gt;Here is the part that generalises past this one unit.&lt;/p&gt;

&lt;p&gt;Ask the reporter what their viewport was and they will send you a screen size, because that is the number a phone knows about itself. It is the wrong number. What decided the layout was whether a piece of browser furniture was expanded at that moment, which is not a device property, not a setting, and not something they can observe. It depends on whether they arrived from a link or a home screen icon, and on whether they had scrolled yet.&lt;/p&gt;

&lt;p&gt;A screenshot does not settle it either. The screenshot shows the visual viewport. Your CSS was reasoning about the large one. Those are the two numbers whose disagreement is the whole bug, and the image contains only one of them.&lt;/p&gt;

&lt;p&gt;Which is the shape of most unreproducible reports, once you look at them: the fact that would decide it is not hidden, it is just not a fact the person in front of the problem is able to know they should write down.&lt;/p&gt;

</description>
      <category>css</category>
      <category>mobile</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
