<?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>Forem: Jurij Tokarski</title>
    <description>The latest articles on Forem by Jurij Tokarski (@jurijtokarski).</description>
    <link>https://forem.com/jurijtokarski</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F883676%2F842c240f-62c1-41f4-ac3d-ac3e1a52a6d9.jpeg</url>
      <title>Forem: Jurij Tokarski</title>
      <link>https://forem.com/jurijtokarski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/jurijtokarski"/>
    <language>en</language>
    <item>
      <title>SVG Animation Is Not DOM Animation</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Mon, 13 Apr 2026 10:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/svg-animation-is-not-dom-animation-56jj</link>
      <guid>https://forem.com/jurijtokarski/svg-animation-is-not-dom-animation-56jj</guid>
      <description>&lt;p&gt;I had a bar chart race sitting in a private repo for over five years. A coding challenge from 2020 or so — built it, moved on, forgot about it. When I started building the &lt;a href="https://dev.to/toolkit"&gt;toolkit&lt;/a&gt; on varstatt.com — free browser-based dev tools — it seemed like an obvious candidate to resurrect.&lt;/p&gt;

&lt;p&gt;The new version would be React with SVG, part of a suite: bar chart race, line chart race, area chart race, bubble chart race. Same idea, four visualizations. Upload a CSV, watch the data animate.&lt;/p&gt;

&lt;p&gt;Every animation technique I reached for broke in a way I didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS Transitions Do Nothing on Geometric Attributes
&lt;/h2&gt;

&lt;p&gt;First attempt on the line chart: CSS transitions on SVG elements. &lt;code&gt;transition: cx 300ms ease, cy 300ms ease&lt;/code&gt; on the &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt; dots tracking data points. Expected smooth interpolation between positions.&lt;/p&gt;

&lt;p&gt;The dots snapped. No animation. Chrome, Firefox, same result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* This does nothing useful */&lt;/span&gt;
&lt;span class="nt"&gt;circle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cx&lt;/span&gt; &lt;span class="m"&gt;300ms&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cy&lt;/span&gt; &lt;span class="m"&gt;300ms&lt;/span&gt; &lt;span class="n"&gt;ease&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;CSS transitions animate CSS properties. &lt;code&gt;cx&lt;/code&gt;, &lt;code&gt;cy&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;, &lt;code&gt;points&lt;/code&gt; are not CSS properties — they're SVG attributes. They live in the DOM, but the browser's animation engine doesn't see them. You can change them from JavaScript and the element moves, but there's no interpolation. It jumps.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt; work because those are actual CSS properties that SVG elements happen to support. Everything that describes SVG geometry — positions, sizes, path data — sits outside that system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Animation Systems on the Same Property
&lt;/h2&gt;

&lt;p&gt;The bar chart race had horizontal bars with CSS transitions on &lt;code&gt;top&lt;/code&gt; and &lt;code&gt;width&lt;/code&gt;. I set &lt;code&gt;transition: top 1000ms ease-out, width 1000ms ease-out&lt;/code&gt; and advanced frames with &lt;code&gt;setInterval&lt;/code&gt;. That worked.&lt;/p&gt;

&lt;p&gt;Then I switched playback to &lt;code&gt;requestAnimationFrame&lt;/code&gt; for continuous interpolation — a float position updating at ~60fps instead of integer jumps every second.&lt;/p&gt;

&lt;p&gt;The bars turned jittery. Every RAF tick (~16ms) set a new &lt;code&gt;top&lt;/code&gt; value. Each value restarted the 1000ms CSS transition before the previous one completed. The browser's transition engine was fighting the RAF loop. Two animation systems controlling the same property, neither finishing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RAF updates position every ~16ms&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;BAR_HEIGHT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// CSS transition: "top 1000ms ease-out"&lt;/span&gt;
&lt;span class="c1"&gt;// Every 16ms: cancel current transition, start new 1000ms transition&lt;/span&gt;
&lt;span class="c1"&gt;// Result: jittery mess&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was to remove every CSS transition from every element that RAF touches. Bar positions, widths, SVG coordinates, label positions — all computed directly from the playback float.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RAF computes position directly — no CSS transition&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interpolatedRank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prevRank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextRank&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevRank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frac&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interpolatedRank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;BAR_HEIGHT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// style={{ top, transition: 'none' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CSS transitions and &lt;code&gt;requestAnimationFrame&lt;/code&gt; are competing strategies.&lt;/strong&gt; They solve the same problem differently. Layering both on the same property means neither works. I ended up with zero CSS transitions on animated properties across all four chart types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Colors That Follow Position Instead of Identity
&lt;/h2&gt;

&lt;p&gt;Bubble chart. Bubbles sorted by value each frame so the largest renders on top (correct z-order). Colors assigned by array index after sorting.&lt;/p&gt;

&lt;p&gt;Frame 1: Python is biggest, gets index 0, gets blue. Frame 2: JavaScript overtakes Python, gets index 0, gets blue. Python drops to index 1, turns orange. Every frame where the lead changes, half the bubbles swap colors.&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;// before: color by sorted position&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;items&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;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: build a color map keyed by series name at parse time, before any sorting happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colorMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;map&lt;/span&gt; &lt;span class="o"&gt;=&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;seriesNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&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;i&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;map&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seriesNames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;palette&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;data.seriesNames&lt;/code&gt; preserves the original CSV column order. It never changes during playback. Sorting for z-order still happens, but it only affects render order, not color. Any visualization where items reorder needs visual properties assigned by identity, never by current array position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranks That Re-Sort Every Tick
&lt;/h2&gt;

&lt;p&gt;Same bar chart race. I was re-sorting bars by their interpolated value on every RAF tick. Values cross each other mid-frame constantly — Python at 11.83 overtakes Java at 11.81 for one tick, then Java is back on top the next. The bars flickered between positions 60 times a second.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;compute sort order only at whole frame boundaries&lt;/strong&gt;, store it in a pre-computed array, then interpolate rank positions as floats between frames.&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;frameRanks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&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;frame&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;sorted&lt;/span&gt; &lt;span class="o"&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;seriesNames&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;name&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;name&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;frame&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="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;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;ranks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;ranks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ranks&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// interpolate rank as a float&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentRanks&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="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextRanks&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="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frac&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;barHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bar sliding from position 3 to position 1 moves smoothly over the full frame duration instead of jumping. No flickering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curves That Rewrite Their Own History
&lt;/h2&gt;

&lt;p&gt;The line and area charts used Catmull-Rom splines. The animation draws a line progressively — like a pen moving across the screen. Curves looked great.&lt;/p&gt;

&lt;p&gt;The problem showed up immediately: as the animation advanced and new points entered the spline, the entire line wiggled. Segments already "drawn" shifted into new positions on every frame.&lt;/p&gt;

&lt;p&gt;Catmull-Rom computes each segment's control points from the tangent at its endpoints, and the tangent at any point depends on its neighbors. Add a new neighbor, all the tangents change. Feed completed points into the spline function as the animation progresses and every frame recalculates every segment. &lt;strong&gt;The old part of the curve is never stable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix split the work into two memos with different dependency arrays.&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;// Phase 1: compute ALL segments from full dataset, once&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stableGeometry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;allPoints&lt;/span&gt; &lt;span class="o"&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;frames&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;f&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;xScale&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="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;yScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seriesName&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;precomputeSegments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allPoints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;allPoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&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="c1"&gt;// Phase 2: reveal progressively, split active segment with de Casteljau&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chartData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stableGeometry&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;wholeIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&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;frac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;wholeIdx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wholeIdx&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;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathData&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="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;frac&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wholeIdx&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splitBezierAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wholeIdx&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&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="nx"&gt;stableGeometry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pre-compute the full curve from all data points. Completed segments render byte-identical every frame. The active segment gets split at the exact fractional position using de Casteljau subdivision. Historical geometry never depends on current playback position.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Common Assumption
&lt;/h2&gt;

&lt;p&gt;Each problem came from expecting SVG elements to behave like DOM elements when animated. CSS transitions ignore geometric attributes. RAF and transitions fight over the same values. Array indices aren't stable identifiers when sort order changes. Spline algorithms that look local are global.&lt;/p&gt;

&lt;p&gt;The fix was the same every time: compute everything yourself, from one source of truth. Zero CSS transitions on animated properties, all positions derived from a single playback float.&lt;/p&gt;

&lt;p&gt;The four chart tools are part of the &lt;a href="https://dev.to/toolkit"&gt;varstatt.com/toolkit&lt;/a&gt; — free, browser-based, no sign-up: &lt;a href="https://dev.to/toolkit/bar-chart-race"&gt;bar chart race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/line-chart-race"&gt;line chart race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/area-chart-race"&gt;area chart race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/bubble-chart-race"&gt;bubble chart race&lt;/a&gt;. The old repo from 2020 bears no resemblance to what shipped.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>45 Tabs I Stopped Opening</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 09 Apr 2026 14:45:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/45-tabs-i-stopped-opening-34n5</link>
      <guid>https://forem.com/jurijtokarski/45-tabs-i-stopped-opening-34n5</guid>
      <description>&lt;p&gt;The JWT decoder I used to reach for sent the token to a server. I noticed because I had DevTools open for something else and saw the POST. A JWT often carries user IDs, emails, roles, expiration data. I'd been pasting production tokens into a stranger's endpoint for months.&lt;/p&gt;

&lt;p&gt;That was the first tool I built for the &lt;a href="https://dev.to/toolkit"&gt;toolkit&lt;/a&gt;. The rest followed the same pattern: I needed something, the available options were ad-heavy or required sign-up or made network calls that didn't need to happen. A Base64 encoder doesn't need a backend. Neither does a regex tester, a color converter, or a hash generator.&lt;/p&gt;

&lt;p&gt;There are 45 tools now. No sign-up, no tracking, no data collection. Most run entirely in the browser — a few like DNS Lookup and SSL Checker need a server call by nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Catalogue
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Encoding&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/base64"&gt;Base64&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/jwt"&gt;JWT Decoder&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/image-base64"&gt;Image to Base64&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/encrypt"&gt;Encrypt / Decrypt&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/hash"&gt;Hash Generator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON &amp;amp; YAML&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/json"&gt;JSON Formatter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/json-yaml"&gt;JSON ↔ YAML&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/yaml-validate"&gt;YAML Validator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Markdown&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/markdown"&gt;Markdown Preview&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/diff"&gt;Text Diff&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/html-to-markdown"&gt;HTML ↔ Markdown&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/markdown-pdf"&gt;Markdown to PDF&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/markdown-docx"&gt;Markdown to DOCX&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/csv-editor"&gt;CSV Editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/qr-code"&gt;QR Code&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/barcode"&gt;Barcode&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/image-convert"&gt;Image Converter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/favicon"&gt;Favicon Generator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/svg-optimizer"&gt;SVG Optimizer&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/image-placeholder"&gt;Placeholder Images&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/aspect-ratio"&gt;Aspect Ratio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/mesh-gradient"&gt;Mesh Gradient&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/css-covers"&gt;CSS Cover Art&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/color"&gt;Color Converter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/text-gradient"&gt;Text to Gradient&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charts&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/bar-chart-race"&gt;Bar Chart Race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/line-chart-race"&gt;Line Chart Race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/bubble-chart-race"&gt;Bubble Chart Race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/area-chart-race"&gt;Area Chart Race&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/dns-lookup"&gt;DNS Lookup&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/cors-tester"&gt;CORS Tester&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/ssl-checker"&gt;SSL Checker&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/og-preview"&gt;OG Tag Validator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/http-status"&gt;HTTP Status Codes&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/robots-txt"&gt;Robots.txt Validator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/sitemap-validator"&gt;Sitemap Validator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/user-agent"&gt;User Agent Parser&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Text&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/regex"&gt;Regex Tester&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/case-converter"&gt;Case Converter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/slug-generator"&gt;Slug Generator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/word-counter"&gt;Word Counter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/copy-paste-character"&gt;Copy Paste Characters&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generators&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/uuid"&gt;UUID&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/password"&gt;Password&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/crontab"&gt;Crontab&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/timestamp"&gt;Unix Timestamp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most are straightforward. Three outgrew the toolkit and became standalone npm packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text to Gradient
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/toolkit/text-gradient"&gt;Text to Gradient&lt;/a&gt; tool and the &lt;a href="https://dev.to/toolkit/mesh-gradient"&gt;Mesh Gradient Generator&lt;/a&gt; both needed the same thing: a way to turn an arbitrary input into a unique, stable visual. Same input, same gradient, every time. No database, no storage.&lt;/p&gt;

&lt;p&gt;A djb2-style 32-bit hash is all it takes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;textHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5381&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&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;Everything derives from that number. &lt;code&gt;hash % palettes.length&lt;/code&gt; selects the color palette. &lt;code&gt;seededRandom(hash + layerIndex * 1000)&lt;/code&gt; generates position and opacity variation per layer. The same string always produces the same gradient — looks hand-crafted, costs nothing to store.&lt;/p&gt;

&lt;p&gt;The gradients themselves are layered &lt;code&gt;radial-gradient()&lt;/code&gt; calls. There's no &lt;code&gt;mesh-gradient()&lt;/code&gt; in CSS. What works is stacking 6-8 radial gradients positioned at organic spots — 15%, 37%, 63%, 82% — not pure corners or centers, which look algorithmic. Each one uses a &lt;code&gt;0px&lt;/code&gt; first stop for a crisp center and &lt;code&gt;transparent&lt;/code&gt; at 50% for soft falloff. The browser composites them in layer order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;background&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;ellipse&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="err"&gt;15&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;120&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;40&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;200&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;9&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="o"&gt;%),&lt;/span&gt;
  &lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;circle&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="err"&gt;80&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;10&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;40&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;180&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;220&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%),&lt;/span&gt;
  &lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;ellipse&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="err"&gt;55&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;75&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;200&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;120&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="err"&gt;55&lt;/span&gt;&lt;span class="o"&gt;%),&lt;/span&gt;
  &lt;span class="err"&gt;#1&lt;/span&gt;&lt;span class="nt"&gt;a0a2e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For tinting — hover states, borders, soft fills — &lt;code&gt;color-mix()&lt;/code&gt; handles it without any HSL arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;background-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--accent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;12&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;white&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;border-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--accent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;25&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing that cost me time: making these dynamic in Tailwind. A template literal like &lt;code&gt;bg-[color-mix(in_srgb,${color}_12%,white)]&lt;/code&gt; silently produces nothing. Tailwind's compiler scans source files for complete static strings at build time. A class assembled from a variable doesn't exist as a string when the scanner runs — it gets skipped with no warning. Inline styles are the fallback for truly dynamic values.&lt;/p&gt;

&lt;p&gt;Text to Gradient is now an &lt;a href="https://www.npmjs.com/package/text-to-gradient" rel="noopener noreferrer"&gt;npm package&lt;/a&gt;. It powers the default cover images across the site when a page has no custom visual. Those covers are also animated — which is where the next package came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loopkit
&lt;/h2&gt;

&lt;p&gt;Every tool, blog post, landing page, and discovery step on varstatt.com has an animated SVG cover — all powered by &lt;a href="https://dev.to/toolkit/loopkit"&gt;Loopkit&lt;/a&gt;. I had ~35 cover designs already in JSX when I started building the engine underneath them. The first decision was whether to keep composable React components or switch to schema-driven JSON.&lt;/p&gt;

&lt;p&gt;JSON won because of output flexibility. A React component locks you into JSX. A schema is data — it can render to HTML for OG images, to SVG for exports, to CSS for emails, or to React for the live site. The core engine has no React dependency.&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;cover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;       &lt;span class="c1"&gt;// full HTML with inline styles&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;      &lt;span class="c1"&gt;// React style objects&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHtml&lt;/span&gt;  &lt;span class="c1"&gt;// just the elements&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hoverCss&lt;/span&gt;   &lt;span class="c1"&gt;// raw CSS rules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Phase ordering.&lt;/strong&gt; I had the cycle structured as: animate forward, hold final frame, fade out, loop. Loop restarts were smooth, but the first &lt;code&gt;play()&lt;/code&gt; call snapped instantly from the held frame to frame 0. Moving the fade to the beginning of the cycle fixed it — every iteration, including the first, starts with a reverse interpolation from wherever the animation sits, then plays forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hover exits.&lt;/strong&gt; &lt;code&gt;mouseenter&lt;/code&gt; called &lt;code&gt;play()&lt;/code&gt;, &lt;code&gt;mouseleave&lt;/code&gt; called &lt;code&gt;reset()&lt;/code&gt;. The reset snapped to the static frame — functional but mechanical. A &lt;code&gt;settle()&lt;/code&gt; method reads the live position and interpolates smoothly from there to the end state over a capped duration. The key: tracking &lt;code&gt;currentAnimElapsed&lt;/code&gt; during active animation is what makes settle() possible. Without it, mouseleave can only snap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stagger math.&lt;/strong&gt; In a staggered loop where each element has its own delay, the cycle duration isn't &lt;code&gt;animDuration&lt;/code&gt;. It's the time until the last element finishes, plus hold time. Using just &lt;code&gt;animDuration&lt;/code&gt; cuts off late-starting elements before they complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastFinish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stagger&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;lastFinish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastFinish&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cycleDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastFinish&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;holdDuration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-centering all 48 schemas programmatically surfaced one more problem. The centering script computes a bounding box, then shifts coordinates to align with the canvas center. Loopkit schemas use &lt;code&gt;[from, to]&lt;/code&gt; arrays for animated values — a bar animates with &lt;code&gt;y: [247, 87]&lt;/code&gt;. The bbox script was reading &lt;code&gt;[0]&lt;/code&gt;, the start value. A bar starting at y=247 with height 180 gave a 427px bounding box on a 280px canvas. The fix was one index: read &lt;code&gt;[1]&lt;/code&gt;, the end state, because that's the visual rest position.&lt;/p&gt;

&lt;p&gt;Loopkit is under 5KB with zero dependencies. It's an &lt;a href="https://www.npmjs.com/package/loopkit" rel="noopener noreferrer"&gt;npm package&lt;/a&gt; now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Markdown Repository
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/toolkit/markdown-repository"&gt;Markdown Repository&lt;/a&gt; began as a utility function inside this site. I query &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.mdx&lt;/code&gt; files by frontmatter — filter by tags, sort by date, paginate. The API looks like Firestore's &lt;code&gt;where&lt;/code&gt;/&lt;code&gt;orderBy&lt;/code&gt;/&lt;code&gt;limit&lt;/code&gt; chain. Once three of my projects used the same copy-pasted code, I extracted it into an &lt;a href="https://www.npmjs.com/package/markdown-repository" rel="noopener noreferrer"&gt;npm package&lt;/a&gt;. The publish pipeline — trusted publishing with OIDC, no stored tokens — turned into &lt;a href="https://dev.to/jurij/p/npm-trusted-publishing-from-github-actions"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full List
&lt;/h2&gt;

&lt;p&gt;45 tools, three npm packages. The full list is at &lt;a href="https://dev.to/toolkit"&gt;varstatt.com/toolkit&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>npm Publish Without Tokens</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 07 Apr 2026 10:35:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/npm-publish-without-tokens-4692</link>
      <guid>https://forem.com/jurijtokarski/npm-publish-without-tokens-4692</guid>
      <description>&lt;p&gt;I published an npm package last week — &lt;a href="https://www.npmjs.com/package/markdown-repository" rel="noopener noreferrer"&gt;markdown-repository&lt;/a&gt;, a Firestore-style query builder for markdown files. The code worked. The tests passed. The release pipeline took longer to get right than the package itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Way
&lt;/h2&gt;

&lt;p&gt;The standard npm publishing workflow uses a long-lived access token. You generate it on npmjs.com, store it as a GitHub Actions secret, and reference it in your workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, but the token never expires, has write access to your packages, and lives in plain text in your CI secrets. If it leaks — through a copied workflow file or a careless log — anyone can publish under your name.&lt;/p&gt;

&lt;p&gt;npm's granular tokens improved this slightly. You can scope them to specific packages and set a 90-day expiration. But you still have to rotate them manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trusted Publishing
&lt;/h2&gt;

&lt;p&gt;npm now supports &lt;a href="https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-trusted-publishing" rel="noopener noreferrer"&gt;trusted publishing with OIDC&lt;/a&gt;. Instead of a stored token, your GitHub Actions workflow proves its identity to npm using a short-lived OpenID Connect credential. npm verifies the credential against the workflow you've authorized, and accepts the publish.&lt;/p&gt;

&lt;p&gt;No token to store. No token to rotate. No token to leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Publish Is Manual
&lt;/h2&gt;

&lt;p&gt;Before you can configure trusted publishing, the package must already exist on the registry. npm has no "pending publisher" feature — you can't set up OIDC for a package that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;For the very first version, publish from your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm login
npm publish &lt;span class="nt"&gt;--access&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I spent a while debugging my workflow before realizing trusted publishing only works from the second release onward. Once the package exists on npmjs.com, go to its settings and add a trusted publisher. From that point, the workflow handles everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Workflow
&lt;/h2&gt;

&lt;p&gt;The setup has two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On npmjs.com&lt;/strong&gt;: go to your package settings, add a trusted publisher. Specify the GitHub org/user, repository, workflow filename, and optionally an environment name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the workflow&lt;/strong&gt;: add &lt;code&gt;id-token: write&lt;/code&gt; permission and an &lt;code&gt;environment&lt;/code&gt; that matches what you configured on npm.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24.x&lt;/span&gt;
          &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry.npmjs.org&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish --provenance --access public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provenance attestation is automatic with trusted publishing. The &lt;code&gt;--provenance&lt;/code&gt; flag is redundant but makes the intent explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Misleading 404
&lt;/h2&gt;

&lt;p&gt;My first three releases failed with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm error 404 Not Found - PUT https://registry.npmjs.org/markdown-repository
npm error 404 'markdown-repository@1.1.0' is not in this registry.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package existed. The version was correct. The OIDC token exchange succeeded — I could see the signed provenance statement in &lt;a href="https://search.sigstore.dev" rel="noopener noreferrer"&gt;Rekor's transparency log&lt;/a&gt;. Everything worked except the actual publish.&lt;/p&gt;

&lt;p&gt;The problem: &lt;strong&gt;Node 22 ships with npm 10.x. Trusted publishing requires npm 11.5.1 or later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;npm's documentation mentions this requirement. The error message doesn't. A 404 on PUT looks like a registry problem or a package name conflict. Nothing points you toward an npm version mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Use Node 24.x in your workflow. On GitHub Actions, &lt;code&gt;node-version: 24.x&lt;/code&gt; resolves to a recent patch that includes npm 11.5.1+ — &lt;a href="https://github.com/varstatt/markdown-repository/blob/main/.github/workflows/publish-package.yaml" rel="noopener noreferrer"&gt;markdown-repository&lt;/a&gt; publishes this way without an explicit npm upgrade.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're stuck on an older Node version, upgrade npm explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g npm@latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With npm 11.5.1+, the same workflow publishes successfully. No tokens needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Environment Mismatch
&lt;/h2&gt;

&lt;p&gt;The same 404 shows up when the &lt;strong&gt;environment name&lt;/strong&gt; on npmjs.com doesn't match the &lt;code&gt;environment&lt;/code&gt; field in your workflow job. If your workflow says &lt;code&gt;environment: release&lt;/code&gt; but npm has the environment field blank (or vice versa), the OIDC claims don't match and npm rejects the publish — with a 404, not a meaningful error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pipeline Looks Like Now
&lt;/h2&gt;

&lt;p&gt;The full workflow for &lt;a href="https://github.com/varstatt/markdown-repository" rel="noopener noreferrer"&gt;markdown-repository&lt;/a&gt; runs lint, tests, and build on every commit. On a GitHub release, it publishes to npm with provenance — no secrets configured anywhere in the repository.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>github</category>
      <category>npm</category>
      <category>security</category>
    </item>
    <item>
      <title>Three Ways the Wrong Value Won</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/three-ways-the-wrong-value-won-49o6</link>
      <guid>https://forem.com/jurijtokarski/three-ways-the-wrong-value-won-49o6</guid>
      <description>&lt;p&gt;A user created a tender and immediately couldn't edit it. Not after a day, not after some permission change — immediately. They hit "Create," the page loaded, and the edit button was grayed out.&lt;/p&gt;

&lt;p&gt;That was the first bug. It took three fixes across two projects before I understood what connected them: in each case, the value that reached the client wasn't the value I'd computed. Something else got there first — by being faster, by being stale, or by being last in the object literal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value That Arrived Too Early
&lt;/h2&gt;

&lt;p&gt;I pulled up the tender document in Firestore. The &lt;code&gt;ai_driver&lt;/code&gt; field was missing entirely. The frontend created tenders like this:&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;tenderData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;companyData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&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;New companies had no &lt;code&gt;ai_driver&lt;/code&gt; set. The conditional spread evaluated to falsy, so the field was never written. That was supposed to be fine — a Cloud Function trigger would set the default after creation.&lt;/p&gt;

&lt;p&gt;The Firestore snapshot listener had other plans. It fired before the Cloud Function, saw no &lt;code&gt;ai_driver&lt;/code&gt;, and ran this check:&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;isDiscontinuedDriver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DISCONTINUED_AI_DRIVERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing field. Falsy. "Discontinued." Read-only. The user just watched their tender lock itself. Every single tender created by a new company since this code shipped had been born locked.&lt;/p&gt;

&lt;p&gt;The fix had two parts. The frontend writes every field it reads immediately after creation — no delegating defaults to triggers:&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;tenderData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_AI_DRIVER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the discontinuation check had to distinguish "missing" from "actively deprecated":&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;isDiscontinuedDriver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;DISCONTINUED_AI_DRIVERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deployed both. Bug reports kept coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value That Outlived Its Meaning
&lt;/h2&gt;

&lt;p&gt;Different users, same symptom. Tenders locked on creation. But these companies had &lt;code&gt;ai_driver&lt;/code&gt; explicitly set in Firestore — set to &lt;code&gt;assistants-api-gpt4o&lt;/code&gt;, a driver I'd discontinued months earlier.&lt;/p&gt;

&lt;p&gt;I traced it to the organization settings form:&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;aiDriver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistants-api-gpt4o&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 hardcoded fallback was a leftover from migration. New companies had no &lt;code&gt;ai_driver&lt;/code&gt; in Firestore, so the form loaded with a dead value nobody could see. The field wasn't even visible on the settings page — it was an internal config, not a user-facing dropdown.&lt;/p&gt;

&lt;p&gt;The form submitted its entire state on every save. A user enables a jurisdiction toggle, hits save, and the payload includes &lt;code&gt;ai_driver: "assistants-api-gpt4o"&lt;/code&gt;. The backend guard:&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&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;Truthy string passes. The discontinued driver gets written to Firestore. Every tender created after that inherits it. The user who toggled a jurisdiction setting three weeks ago has no idea they just broke tender creation for their entire organization.&lt;/p&gt;

&lt;p&gt;I dropped the hardcoded fallback. Deployed. Reports kept coming — users had the old bundle cached. Every save from a cached session re-wrote the stale value, undoing any Firestore cleanup I ran manually.&lt;/p&gt;

&lt;p&gt;The frontend fix wasn't the real fix. The real fix was backend enum validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AIDriver&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend rejects any value not in the current enum. Cached bundles, stale defaults, garbage input — all dropped. The frontend can send whatever it wants; the backend is the last line, and it has to act like it.&lt;/p&gt;

&lt;p&gt;That stopped the bleeding. But the pattern was already in my head when I opened a different codebase weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value That Was Always Last
&lt;/h2&gt;

&lt;p&gt;I was reviewing a feature flag called &lt;code&gt;ai_chat_enabled&lt;/code&gt;. The backend computed it from the user's subscription plan — a careful if/else chain that looked up the plan, checked edge cases, and resolved to a boolean. Solid logic. Well-tested in isolation.&lt;/p&gt;

&lt;p&gt;Then I looked at the response builder:&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&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="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;customerPreferences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;customerPreferences&lt;/code&gt; came from DynamoDB. It contained its own &lt;code&gt;ai_chat_enabled&lt;/code&gt; key — the raw stored preference, not the computed one. The spread came after the explicit assignment.&lt;/p&gt;

&lt;p&gt;JavaScript object literals follow last-writer-wins. The spread silently overwrote the computed value with whatever was sitting in the database. The entire plan-based computation — the lookup, the edge cases, the if/else chain — never reached the client. Not once. Not since the day this code shipped.&lt;/p&gt;

&lt;p&gt;The tests checked that the computation logic returned the right boolean. They never checked that the response builder actually used it.&lt;/p&gt;

&lt;p&gt;The fix was one line — move the spread before the explicit fields:&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&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="nx"&gt;customerPreferences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&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="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ai_chat_enabled&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;Computed values last. Raw data first. The spread provides defaults; the explicit fields override them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong Value Always Has a Way In
&lt;/h2&gt;

&lt;p&gt;Timing, staleness, ordering. Three mechanisms, same result: the value I intended never made it. If the frontend reads a field, the backend must validate it. If the backend computes a value, nothing downstream should be able to quietly replace it. The wrong value will always find a way in. The only defense is making sure the right value goes last.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>An Empty AI Response Corrupted Chat History</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/an-empty-ai-response-corrupted-chat-history-1ap</link>
      <guid>https://forem.com/jurijtokarski/an-empty-ai-response-corrupted-chat-history-1ap</guid>
      <description>&lt;p&gt;The spinner ran. The stream closed. The chat bubble stayed empty. No error anywhere.&lt;/p&gt;

&lt;p&gt;I was building a conversational discovery tool for founders — a multi-step Gemini-powered flow that walked people through product decisions, collected answers, and built a structured brief. Complex setup: long system prompt, tool definitions, large user messages. Genkit's &lt;code&gt;generateStream&lt;/code&gt; handling each turn.&lt;/p&gt;

&lt;p&gt;Intermittently, a user would send a message and get nothing back. No timeout, no catch block firing, no non-2xx status. Just a clean stream completion with zero content inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Logs Said When I Added Them
&lt;/h2&gt;

&lt;p&gt;Standard error handling gives you no signal here:&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// exits immediately — no chunks arrive&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// response.text() returns ''&lt;/span&gt;
  &lt;span class="c1"&gt;// no exception thrown&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// never reached&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding chunk-level logging made it visible. The stream was completing, but the one chunk that arrived looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chunk #1 has no content.
Keys: [ 'index', 'role', 'content', 'custom', 'previousChunks', 'parser' ]
role: model
content.length: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;content&lt;/code&gt; property existed. It wasn't null. It was an empty array. The keys &lt;code&gt;custom&lt;/code&gt;, &lt;code&gt;previousChunks&lt;/code&gt;, and &lt;code&gt;parser&lt;/code&gt; are Genkit's internal markers for a thinking chunk. The model had spent the entire response budget on internal reasoning and had nothing left to output. HTTP 200. Genkit reported success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Ways to Get Nothing
&lt;/h2&gt;

&lt;p&gt;Gemini 2.5 Flash ships with thinking mode enabled by default. Under normal inputs that's fine. Under heavy inputs — long system prompt plus tool definitions plus a long user message — it can exhaust the entire token budget on reasoning before producing a single output token.&lt;/p&gt;

&lt;p&gt;There's a second cause that produces the same result: silent rate limiting. Rather than returning a 4xx, Gemini returns a valid, complete, empty stream. The observable symptom is identical. The detection is identical: assert that at least one content chunk arrived after the stream closes.&lt;/p&gt;

&lt;p&gt;For the thinking mode case, the fix is one line in the Genkit config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;thinkingConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;thinkingBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;thinkingBudget: 0&lt;/code&gt; disables extended thinking. For a conversational flow where latency matters more than deep reasoning, there's no reason to let the model spend the budget on internal traces.&lt;/p&gt;

&lt;p&gt;Fix deployed. I moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Save That Made It Permanent
&lt;/h2&gt;

&lt;p&gt;What I hadn't checked: the database. Every one of those empty responses had already been saved to Firestore. An empty string is a valid string. The save ran. Nothing flagged it.&lt;/p&gt;

&lt;p&gt;The stream handler read &lt;code&gt;finalResult.text&lt;/code&gt; after &lt;code&gt;generateStream&lt;/code&gt; resolved and wrote it as the AI's message. When thinking mode ate the budget, &lt;code&gt;finalResult.text&lt;/code&gt; was &lt;code&gt;""&lt;/code&gt;. Firestore now held a record of every affected conversation — each one storing a legitimate-looking AI turn with no content.&lt;/p&gt;

&lt;h2&gt;
  
  
  History as Poison
&lt;/h2&gt;

&lt;p&gt;When those users came back and sent new messages, &lt;code&gt;getChatHistory&lt;/code&gt; pulled their messages from Firestore and formatted them for Gemini:&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;return&lt;/span&gt; &lt;span class="nx"&gt;messages&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;msg&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;When &lt;code&gt;msg.content&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;, that produces &lt;code&gt;{ role: "model", content: [{ text: "" }] }&lt;/code&gt;. A valid-looking empty model turn in the middle of a real conversation. Gemini received it, interpreted it as unfinished context, entered thinking mode to reason about it, exhausted the budget, returned nothing — which got saved as another empty message, which poisoned the next turn.&lt;/p&gt;

&lt;p&gt;The conversation was permanently, silently broken. No exception at any layer. No signal the user could act on. Just a chat that would never respond again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix That Requires Two Places
&lt;/h2&gt;

&lt;p&gt;Fixing only the stream detection isn't enough — the database is already corrupted. Fixing only the history filter isn't enough — new empty responses can still arrive and be saved. Both defenses are required.&lt;/p&gt;

&lt;p&gt;Never write an empty AI message:&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;finalText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;accumulatedText&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;finalResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAIMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[StreamHandler] Skipping empty AI message save&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And filter empty turns before sending history to the model:&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;return&lt;/span&gt; &lt;span class="nx"&gt;messages&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;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;msg&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;Miss either one and the loop can restart. The stream guard stops new corruption. The history filter handles the records already in the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Retry That Made It Worse
&lt;/h2&gt;

&lt;p&gt;The first instinct after detecting an empty stream was to retry. The naive retry called the same send function — which re-inserted the user's message into the messages array. The model received the question twice. On an already-stressed conversation with heavy context, this accelerated the problem rather than resolving it.&lt;/p&gt;

&lt;p&gt;The fix is an &lt;code&gt;isRetry&lt;/code&gt; flag that skips message insertion on retry calls:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;streamMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isRetry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;isRetry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setChatMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aiMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setChatMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&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;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;aiMsgId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aiMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;streamAIResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user message stays in history exactly once. Without this, retry logic breaks an already-broken conversation faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Every Layer Said "Success"
&lt;/h2&gt;

&lt;p&gt;What made this hard to debug: every layer reported success. HTTP 200, no caught exceptions, valid Firestore writes, clean history formatting. The failure was in the semantics, not the mechanics. An empty model turn is not a successful model turn — and asserting that distinction at each boundary is the only thing that stops the loop.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>javascript</category>
      <category>llm</category>
    </item>
    <item>
      <title>Software Engineering Principles for Startups</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/software-engineering-principles-for-startups-3215</link>
      <guid>https://forem.com/jurijtokarski/software-engineering-principles-for-startups-3215</guid>
      <description>&lt;p&gt;Most software engineering principles are written for teams of 50. Agile ceremonies, sprint retrospectives, quarterly planning — built for organizations, not for founders shipping products.&lt;/p&gt;

&lt;p&gt;I run a solo development studio. I ship to production every week, manage multiple client projects simultaneously, and maintain everything I build. Over the years I wrote down the principles that make this work. There are &lt;a href="https://varstatt.com/principles" rel="noopener noreferrer"&gt;33 of them&lt;/a&gt;, organized across five areas: philosophy, discovery, delivery, partnership, and diligence.&lt;/p&gt;

&lt;p&gt;Here's what actually matters when you're building software for startups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With What's Worth Building
&lt;/h2&gt;

&lt;p&gt;The most expensive software is software that shouldn't exist. Before writing any code, I run every project through a simple filter: &lt;a href="https://varstatt.com/principles/discovery/worth-building" rel="noopener noreferrer"&gt;is this worth building?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most ideas aren't. Not because they're bad ideas — but because they solve the wrong problem, or solve it at the wrong time, or solve it for a market that doesn't care enough to pay.&lt;/p&gt;

&lt;p&gt;When something passes that filter, the next step is &lt;a href="https://varstatt.com/principles/discovery/find-the-core" rel="noopener noreferrer"&gt;finding the core&lt;/a&gt; — the one capability that makes this product exist. Not the feature list. Not the competitor parity matrix. The single thing that, if it doesn't work, means nothing else matters.&lt;/p&gt;

&lt;p&gt;Jane's booking app needed staff-to-service matching that handled real salon complexity. Everything else — payment processing, notifications, calendar sync — is infrastructure you can buy. The core is the only part worth building custom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the Budget, Flex the Scope
&lt;/h2&gt;

&lt;p&gt;Startups don't have unlimited time or money. The traditional approach — estimate everything, add buffer, hope it fits — doesn't work because estimates are wrong.&lt;/p&gt;

&lt;p&gt;I use &lt;a href="https://varstatt.com/principles/discovery/appetite-not-estimates" rel="noopener noreferrer"&gt;appetite, not estimates&lt;/a&gt;. You decide how much time a problem is worth — two weeks, six weeks — and that's your constraint. Then &lt;a href="https://varstatt.com/principles/discovery/scope-shaping" rel="noopener noreferrer"&gt;scope shaping&lt;/a&gt; fits what you build inside that box.&lt;/p&gt;

&lt;p&gt;This sounds backwards but it changes everything. Instead of "how long will this take?" the question becomes "what's the best version we can ship in three weeks?" That question has a useful answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship Continuously, Not Eventually
&lt;/h2&gt;

&lt;p&gt;Startup velocity comes from short feedback loops. Every principle in my &lt;a href="https://varstatt.com/principles/delivery" rel="noopener noreferrer"&gt;delivery system&lt;/a&gt; optimizes for one thing: getting working software in front of users faster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/delivery/wip-one" rel="noopener noreferrer"&gt;WIP One&lt;/a&gt; means one task in progress at a time. Finish it, deploy it, move on. Context switching kills solo developers faster than bad architecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/delivery/production-is-done" rel="noopener noreferrer"&gt;Production is done&lt;/a&gt; means nothing counts until it's live. Not "done on my machine." Not "ready for review." Live in production with monitoring in place. This sounds obvious but most projects have weeks of "almost done" work that never ships.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/delivery/continuous-flow" rel="noopener noreferrer"&gt;Continuous flow&lt;/a&gt; replaces sprints with a priority queue. No sprint planning, no velocity tracking, no ceremony. Just: what's most important right now? Do that. Deploy it.&lt;/p&gt;

&lt;p&gt;For startup teams, this means you can change direction on Monday and ship the new thing by Wednesday. No "we'll add it to next sprint."&lt;/p&gt;

&lt;h2&gt;
  
  
  Software Development Is a Cost, Not a Craft
&lt;/h2&gt;

&lt;p&gt;This is the one that makes developers uncomfortable: &lt;a href="https://varstatt.com/principles/philosophy/business-cost" rel="noopener noreferrer"&gt;software development is a business cost&lt;/a&gt;. It's an operational expense, like rent or hosting.&lt;/p&gt;

&lt;p&gt;That doesn't mean quality doesn't matter. It means quality serves the business, not the developer's ego. The &lt;a href="https://varstatt.com/principles/delivery/scout-rule" rel="noopener noreferrer"&gt;scout rule&lt;/a&gt; — leave the codebase better than you found it — keeps quality high without separate "refactoring sprints" that never get prioritized.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/philosophy/consolidation" rel="noopener noreferrer"&gt;Consolidation&lt;/a&gt; means fewer tools, fewer vendors, fewer moving parts. Every additional service is another bill, another dashboard, another thing that breaks at 2 AM. For startups, simplicity is a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the Boring Parts Last
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/discovery/context-over-purity" rel="noopener noreferrer"&gt;Context over purity&lt;/a&gt; means making pragmatic decisions, not architecturally perfect ones. Use the default stack. Buy what you can. Build only what's core.&lt;/p&gt;

&lt;p&gt;I keep a &lt;a href="https://varstatt.com/principles/delivery/default-stack" rel="noopener noreferrer"&gt;default stack&lt;/a&gt; and use it for everything unless there's a specific reason not to. Deep expertise in familiar tools beats starting fresh with the "best" technology for each project.&lt;/p&gt;

&lt;p&gt;When a client asks "should we use microservices?" the answer is almost always no. Not because microservices are bad — because for a startup, a monolith you ship in three weeks beats a distributed system you ship in three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transparency Over Everything
&lt;/h2&gt;

&lt;p&gt;Startup partnerships fail on misaligned expectations, not technical problems. Every &lt;a href="https://varstatt.com/principles/partnership" rel="noopener noreferrer"&gt;partnership principle&lt;/a&gt; I follow addresses this directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/partnership/transparency" rel="noopener noreferrer"&gt;Transparency&lt;/a&gt; means full visibility into progress, problems, and decisions. No weekly status reports that hide bad news. When something goes wrong — and it will — the client knows the same day.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/partnership/weekly-accountability" rel="noopener noreferrer"&gt;Weekly accountability&lt;/a&gt; creates a billing cycle that forces honest conversations. If the week didn't produce visible progress, that's a problem we discuss before the next week starts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/partnership/exit-freedom" rel="noopener noreferrer"&gt;Exit freedom&lt;/a&gt; means clients can leave at any time. No contracts, no lock-in, no hard feelings. If the work isn't valuable, you should be able to stop paying for it immediately. This keeps me accountable in a way that six-month contracts never could.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance Is Not a Phase
&lt;/h2&gt;

&lt;p&gt;The biggest lie in software development: "We'll build it, launch it, then maintain it." As if building and maintaining are separate activities.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/diligence/no-split" rel="noopener noreferrer"&gt;No split&lt;/a&gt; means development and maintenance happen continuously. Every feature I ship includes monitoring. Every deployment includes the ability to roll back. &lt;a href="https://varstatt.com/principles/delivery/quality-gates" rel="noopener noreferrer"&gt;Quality gates&lt;/a&gt; and &lt;a href="https://varstatt.com/principles/delivery/feature-flags" rel="noopener noreferrer"&gt;feature flags&lt;/a&gt; make it safe to fail and fast to fix.&lt;/p&gt;

&lt;p&gt;For startups, this means you don't need a separate "operations team" from day one. The development process IS the operations process. Ship code, watch it run, fix what breaks, improve what works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full System
&lt;/h2&gt;

&lt;p&gt;These principles aren't independent tips — they form a system. Discovery principles prevent you from building the wrong thing. Delivery principles get the right thing shipped fast. Partnership principles keep everyone aligned. Diligence principles make sure it keeps working.&lt;/p&gt;

&lt;p&gt;I documented all &lt;a href="https://varstatt.com/principles" rel="noopener noreferrer"&gt;33 principles&lt;/a&gt; as a reference — not as rules to follow blindly, but as a starting point for founders who want their engineering process to actually work.&lt;/p&gt;

&lt;p&gt;The best engineering principles for your startup are the ones that let you ship every week. Everything else is overhead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live: &lt;a href="https://varstatt.com/principles" rel="noopener noreferrer"&gt;varstatt.com/principles&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>productivity</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why Scrum Fails In Small Teams</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/why-scrum-fails-in-small-teams-25a6</link>
      <guid>https://forem.com/jurijtokarski/why-scrum-fails-in-small-teams-25a6</guid>
      <description>&lt;p&gt;A few years ago, my development team of three was sitting through a 90-minute sprint planning ceremony. The feature we planned took two days to build.&lt;/p&gt;

&lt;p&gt;We spent more time estimating and discussing the work than doing it. I was the team lead, and this was the moment I started questioning what we were actually doing here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scrum Solved a Real Problem — Then Became One
&lt;/h2&gt;

&lt;p&gt;Scrum is a project management framework built around fixed-length iterations called sprints — usually two weeks. Each sprint has a planning ceremony, daily standups, a review, and a retrospective. There's a product owner who manages the backlog, a scrum master who facilitates the process, and a development team that executes.&lt;/p&gt;

&lt;p&gt;It was created in the 1990s to bring structure to software projects that were failing under waterfall — the old approach of planning everything upfront, building for months, and hoping the result matched reality. Scrum introduced short feedback cycles. Ship something every two weeks. Inspect and adapt. That was genuinely better than what came before.&lt;/p&gt;

&lt;p&gt;The agile manifesto that underpins scrum development prioritizes individuals over processes, working software over documentation, customer collaboration over contracts, and responding to change over following a plan. Good principles. The problem is what the industry built on top of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sprint Boundaries Are Artificial
&lt;/h2&gt;

&lt;p&gt;Tasks don't fit neatly into two-week boxes. Some take three days. Some take twelve. Forcing them into fixed time boundaries creates two failure modes: you either pad estimates to fill the sprint, or you rush to hit an arbitrary deadline that has nothing to do with the actual complexity.&lt;/p&gt;

&lt;p&gt;When a &lt;a href="https://dev.to/principles/partnership/priorities-not-scope"&gt;priority shifts mid-sprint&lt;/a&gt;, scrum says wait until the next planning ceremony. In a small team, that's absurd. The client calls, explains why Feature B is now urgent, and you should be able to switch today — not in nine days when the sprint ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Velocity Tracking Becomes Theater
&lt;/h2&gt;

&lt;p&gt;Story points were meant to help teams estimate work. In practice, they become a performance metric. Teams optimize for point throughput instead of actual value delivered. A refactoring task that prevents six months of tech debt gets 2 points. A trivial UI change that the PM can demo gets 8.&lt;/p&gt;

&lt;p&gt;When one person does the work, velocity tracking is particularly absurd. You already know your throughput. You lived it yesterday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ceremonies Replace Communication
&lt;/h2&gt;

&lt;p&gt;Daily standups. Sprint planning. Sprint review. Sprint retrospective. Backlog grooming. For a team of fifteen with cross-functional dependencies, these rituals serve a real purpose — they force information sharing that wouldn't happen naturally.&lt;/p&gt;

&lt;p&gt;For a team of three? Or a solo developer working with a client? These meetings replace the actual communication they were designed to facilitate. You don't need a standup when you can send an async update after each work session. You don't need sprint planning when the priority queue is a shared list that either side can reorder at any time.&lt;/p&gt;

&lt;p&gt;When the framework produces more Jira tickets, confluence pages, and status updates than actual shipped code, something has gone wrong. The best process is invisible — it stays out of the way while work gets done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Time I Switched to Kanban, Delivery Rocketed
&lt;/h2&gt;

&lt;p&gt;I've led dev teams twice. Both times we started with scrum because that's what the organization used. Both times we shifted toward kanban. And both times the same thing happened: delivery rocketed and people became happier.&lt;/p&gt;

&lt;p&gt;The only meeting that survived was a real daily standup — five minutes to talk about blockers and maybe share plans. That's it. The entire status was visible on the Jira board. Anyone could look at it anytime. No ceremony needed to extract information that was already public.&lt;/p&gt;

&lt;p&gt;I've shipped software since 2011. Now I run my own practice based on &lt;a href="https://dev.to/principles/delivery/continuous-flow"&gt;continuous flow&lt;/a&gt; — Kanban, not Scrum. Here's how it works:&lt;/p&gt;

&lt;h2&gt;
  
  
  A Priority Queue, Not a Sprint Backlog
&lt;/h2&gt;

&lt;p&gt;The client maintains a ranked list. The top item is the highest priority. I work top-down: finish what's in front, then pull the next thing. Priorities shift? The client reorders the list. No replanning ceremony. No negotiating what fits in the sprint. The developer is always working on what matters most right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Thing at a Time, Then Ship It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/principles/delivery/wip-one"&gt;One task at a time&lt;/a&gt;. Finish it. Deploy it. Then move on. This forces honest prioritization and kills context switching. It prevents the trap of being "90% done on five things" while nothing is actually working.&lt;/p&gt;

&lt;p&gt;Code review isn't done. QA passed isn't done. Merged isn't done. &lt;a href="https://dev.to/principles/delivery/production-is-done"&gt;Working in production is done&lt;/a&gt;. This changes how you think about deployment. If deploying is hard, it gets avoided. If it's easy, it happens constantly. Feature flags handle incomplete work — deploy behind the flag, keep building, flip it when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async Updates Beat Standups
&lt;/h2&gt;

&lt;p&gt;Updates go out after each work session — not at end of day, not at a standup, but when the work is actually done. Meetings happen only for decisions that genuinely need real-time discussion. Everything else is written. This keeps calendars empty and &lt;a href="https://dev.to/principles/partnership/async-first"&gt;focus time protected&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For significant features, I think in six-week cycles — long enough to deliver something end-to-end valuable, short enough to stay honest. A cycle isn't a deadline. It's a planning horizon. "In six weeks, we expect X to be working." The cycle serves orientation, not ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  For Big Orgs, Scrum Is Still Revolutionary
&lt;/h2&gt;

&lt;p&gt;I'm not anti-process. I'm anti-unnecessary-process.&lt;/p&gt;

&lt;p&gt;For old-school corporations that have been running waterfall for decades, scrum is genuinely revolutionary. It introduces feedback loops, iterative delivery, and customer involvement where none existed before. That's a massive upgrade. If scrum is moving your 200-person org from annual releases to biweekly ones — keep going. That's real progress.&lt;/p&gt;

&lt;p&gt;Scrum works when you have large teams with cross-functional dependencies, regulated environments where audit trails are compliance requirements, organizations that need guardrails to prevent chaos, or teams coming from waterfall who need a stepping stone.&lt;/p&gt;

&lt;p&gt;But your dev team of four is probably shooting itself in the foot with this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small Is a Strength, Not a Problem to Fix
&lt;/h2&gt;

&lt;p&gt;Here's what I see constantly: small teams and startups adopting processes designed for organizations ten times their size. Scrum is one of those processes. So are SAFe, detailed PRDs, elaborate RACI matrices, and weekly all-hands with thirty-slide decks.&lt;/p&gt;

&lt;p&gt;It comes from the same instinct — wanting to look and feel like a "real" company. But it's backwards. Being small is not a weakness to compensate for. It's an advantage to exploit.&lt;/p&gt;

&lt;p&gt;A team of four can make a decision in a Slack thread that would take a 40-person team two sprint ceremonies and a steering committee. You can deploy a hotfix in twenty minutes while a large org is still scheduling the incident review. You can pivot your roadmap over lunch.&lt;/p&gt;

&lt;p&gt;My advice: use the strength you actually have. You're small, so act quick. Don't import the overhead of organizations that would kill to have your agility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Process Disappears
&lt;/h2&gt;

&lt;p&gt;The agile manifesto got it right: individuals and interactions over processes and tools. Somewhere along the way, the industry built an entire certification industry, a tooling ecosystem, and a consulting practice around processes and tools.&lt;/p&gt;

&lt;p&gt;The best development process is the one you don't notice. Work comes in, gets prioritized, gets built, gets shipped. No theater. No rituals that exist to feel productive rather than be productive.&lt;/p&gt;

&lt;p&gt;Build it. Deploy it. Get feedback. Pull the next priority.&lt;/p&gt;

</description>
      <category>agile</category>
      <category>discuss</category>
      <category>management</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Three Bugs That Were Actually My Prompts</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 19 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/three-bugs-that-were-actually-my-prompts-3bc7</link>
      <guid>https://forem.com/jurijtokarski/three-bugs-that-were-actually-my-prompts-3bc7</guid>
      <description>&lt;p&gt;Three debugging sessions. Three different features. Every investigation eventually landed in the same place: my own prompt files.&lt;/p&gt;

&lt;p&gt;The AI wasn't broken. I was a contradictory author.&lt;/p&gt;

&lt;h2&gt;
  
  
  The STRICT Rule That Was Overriding Itself
&lt;/h2&gt;

&lt;p&gt;I built a structured interview tool — the kind that walks a founder through their idea one question at a time. The system prompt had this near the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STRICT: Ask only ONE question per message. Never bundle questions.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users kept getting messages like "How will you make money? What are the major costs to build and run this?" I read the prompt again. Rule was right there. Added emphasis. Still happened. Moved it higher. Still happened.&lt;/p&gt;

&lt;p&gt;Then I read the interview flow section — the part describing what topics to cover across the session. Step 4 read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**Revenue Streams + Cost Structure**&lt;/span&gt; — How will you make money?
What are the major costs to build and run this?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model wasn't defying the STRICT rule. It was following the flow description, which listed two topics as a single step and framed them as two inline questions. That structure implicitly granted permission to bundle. The more specific instruction — a concrete flow item with actual question text — overrode the more abstract one.&lt;/p&gt;

&lt;p&gt;The fix was two things. Unbundle every flow item into separate steps. And add a concrete bad example directly inside the STRICT rule — not just the prohibition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STRICT: Ask only ONE question per message. Never bundle questions.
Example of what NOT to do: "How will you make money? What are your costs?"
is TWO questions — send one, wait for the answer, then ask the next.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Abstract rules lose to specific structural descriptions. The model resolves contradictions by specificity, not by which rule came first or which one you emphasized. If your flow section describes two questions in the same bullet, that description is an instruction — regardless of what you wrote elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tool That Read the Prohibition
&lt;/h2&gt;

&lt;p&gt;After a discovery session, users could request a full report by email. The tool was registered. The backend handler existed. Users clicked the button. The AI said it couldn't send emails.&lt;/p&gt;

&lt;p&gt;I checked tool registration — correct. Checked the API call — correct. Checked the backend handler — correct. Everything looked wired up properly at every technical layer.&lt;/p&gt;

&lt;p&gt;The issue was in a place I hadn't thought to look. I grepped the prompt files for "report":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"report"&lt;/span&gt; mod/discovery/steps/&lt;span class="k"&gt;*&lt;/span&gt;/prompt.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single step prompt had lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do NOT offer to send a report.
Do NOT mention sending a report.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd written those prohibitions months earlier during a different phase of the project. The tool didn't exist yet when I wrote them. By the time it did, I'd forgotten those lines were there.&lt;/p&gt;

&lt;p&gt;The model wasn't defective. It was obedient to instructions I'd authored and then lost track of. Ten minutes of grepping would have found this immediately. Instead I spent days checking tool registration and API calls.&lt;/p&gt;

&lt;p&gt;Before investigating code when an AI-powered feature does nothing, grep your prompt files for explicit prohibitions against the behavior you're expecting. Search for "do not" and "don't" across your entire prompt corpus against the relevant action. It takes ten seconds and it would have saved me days on this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Precondition That Lived Only in Prose
&lt;/h2&gt;

&lt;p&gt;After fixing the prohibitions, a new problem surfaced. The model was supposed to ask for the user's email before calling &lt;code&gt;send_report&lt;/code&gt;. The prompt said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ALWAYS ask for the user's email before calling send_report.
Never call send_report without confirmed contact details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In testing, the tool got called with &lt;code&gt;founder@example.com&lt;/code&gt;. A placeholder the model had generated rather than asking for a real address. The instruction was clear. The model treated it as a suggestion.&lt;/p&gt;

&lt;p&gt;I made the prompt stronger. Same result — it would comply sometimes, skip the step other times, depending on how the conversation had flowed. Prompt-only enforcement of a precondition is probabilistic.&lt;/p&gt;

&lt;p&gt;The fix was to move validation into the tool handler itself:&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;PLACEHOLDER_DOMAINS&lt;/span&gt; &lt;span class="o"&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;example.com&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;test.com&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;placeholder.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No email provided. Ask the user for their email address before calling this tool.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLACEHOLDER_DOMAINS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;error&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;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" looks like a placeholder. Ask the user for their real email address.`&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;Two things to notice. First, the validation returns errors instead of throwing them. A thrown exception terminates the tool call with a runtime error the model can't act on. A returned error lands back in the model's context as a tool result — the model reads it, understands what went wrong, and retries:&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;// This crashes. The model gets a runtime error and no useful signal.&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;user_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_email is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// This works. The model reads the error and asks for the real address.&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;user_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_email is required. Ask the user for their email address, then call this tool again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;code&gt;required&lt;/code&gt; in a tool schema is a hint to the model, not a runtime guarantee. Models will omit required fields — sometimes because the value wasn't extracted yet, sometimes for reasons that aren't obvious from the logs. Treat every parameter as potentially absent at the handler boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALWAYS X&lt;/code&gt; in a prompt is a suggestion. Enforcing X belongs in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Is the Program
&lt;/h2&gt;

&lt;p&gt;All three bugs came from the same misread of what a system prompt is. I was treating it as documentation — a description of intended behavior that the real system (the code) would enforce. For an LLM-powered feature, that's backwards.&lt;/p&gt;

&lt;p&gt;The system prompt isn't documentation. It's source code executed by a natural-language interpreter. Contradictions in it don't fail to compile — they resolve according to specificity and proximity rules you never wrote down. Prohibitions execute. Structure is semantics. A flow description with two inline questions is an instruction to ask two questions, regardless of the STRICT rule above it.&lt;/p&gt;

&lt;p&gt;The debugging instinct to check the API, the tool registration, the network logs — all of that is valid. But it should come after you've read your own prompts as a hostile reader looking for contradictions, prohibitions, and preconditions that only exist in prose.&lt;/p&gt;

&lt;p&gt;The model is rarely the bug. Read your prompts first.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devjournal</category>
      <category>llm</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Nobody Finishes a 15-Minute AI Interview</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/nobody-finishes-a-15-minute-ai-interview-2paf</link>
      <guid>https://forem.com/jurijtokarski/nobody-finishes-a-15-minute-ai-interview-2paf</guid>
      <description>&lt;p&gt;Last year I launched an AI-powered discovery tool for software founders. The idea was simple: instead of paying for a product consultant, sit through a 15-minute AI interview and get a comprehensive development roadmap. Business model, market sizing, personas, competitive analysis, PRD, tech stack, budget, action plan — all in one session, delivered as a PDF report.&lt;/p&gt;

&lt;p&gt;The output was genuinely useful. Founders who completed it got something they could hand to a developer and start building from.&lt;/p&gt;

&lt;p&gt;But most founders didn't complete it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Sessions Died
&lt;/h2&gt;

&lt;p&gt;I didn't need sophisticated analytics to see the pattern. Founders would start, get three or four exchanges in, and disappear. Not because the questions were wrong. Because they'd hit a question they couldn't answer yet.&lt;/p&gt;

&lt;p&gt;"What's your monetization model?" at minute six, right after they'd just gotten excited describing the product idea. Or a market sizing question when they hadn't done that research. The session demanded answers in a fixed order. Real founder thinking doesn't work that way.&lt;/p&gt;

&lt;p&gt;I spent weeks trying to fix the session — better prompts, shorter flows, smarter branching. None of it changed the completion rate. I was solving the wrong problem: "how do I get founders to finish a 15-minute interview" instead of "what does a founder actually need, when they need it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Insight Came From SEO
&lt;/h2&gt;

&lt;p&gt;While researching keywords for content, I noticed something. "Competitive analysis template for startups" — thousands of monthly searches. "TAM SAM SOM calculator" — same. "PRD generator" — same. Each stage of the founder journey had its own search intent, its own moment of urgency.&lt;/p&gt;

&lt;p&gt;I had been thinking about building a standalone tool around one of these keywords. Then it struck me: my discovery tool already does all of this and more. But a founder searching for "lean canvas generator" doesn't think of it as part of a 15-minute discovery interview. They want the canvas. Right now.&lt;/p&gt;

&lt;p&gt;The monolithic tool was doing eight things well, packaged in a way that required commitment to all eight. The fix wasn't better prompting. It was decomposition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight Tools, Eight Deliverables
&lt;/h2&gt;

&lt;p&gt;The rebuild started with twelve steps, got trimmed to ten, and settled at eight. One per stage of the founder journey:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/business-model-canvas"&gt;Business Model Canvas&lt;/a&gt; — lean canvas with revenue streams, cost structure, key partners&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/competitive-analysis"&gt;Competitive Analysis&lt;/a&gt; — positioning matrix, differentiation signals, competitor tech indicators&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/market-sizing"&gt;Market Sizing&lt;/a&gt; — TAM/SAM/SOM with growth assumptions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/user-personas"&gt;User Personas&lt;/a&gt; — typed persona objects with platform preferences and jobs-to-be-done&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/feature-prioritization"&gt;Feature Prioritization&lt;/a&gt; — domain classification (core / supporting / generic)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/tech-strategy"&gt;Tech Strategy&lt;/a&gt; — build-vs-buy decisions mapped to domain classification, specific stack recommendations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/product-requirements"&gt;Project Requirements&lt;/a&gt; — scoped feature list, acceptance criteria, out-of-scope boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/build-cost-plan"&gt;Build Cost &amp;amp; Plan&lt;/a&gt; — weekly estimate with a concrete action plan attached&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last two were originally four separate tools: Build vs Buy, Tech Stack Advisor, MVP Cost Estimator, and Action Plan. I merged them in pairs. "Should I build auth?" and "which auth provider?" aren't sequential questions — they're the same question. A cost estimate without an action plan is just a number that makes founders anxious. Eight made more sense than ten or twelve.&lt;/p&gt;

&lt;p&gt;Each tool is fully self-contained. It works with no prior context, no prior steps. But designed to hand off cleanly if the founder continues.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Each tool gets its own SEO landing page — keyword-targeted hero, explanation copy, FAQ, and an input form, all server-rendered. The page doubles as the app: before generation it's a landing page Google can crawl, after the founder starts it becomes the chat interface. One URL, two render states.&lt;/p&gt;

&lt;p&gt;The chat itself is a streaming conversation with a constrained AI model. Each tool has its own system prompt scoped to the decisions that step owns — Feature Prioritization scores by business value only, no effort or cost questions (those belong to later steps). The AI drives the conversation, but the scope is narrow: ask the right questions for this deliverable, produce a typed artifact, stop.&lt;/p&gt;

&lt;p&gt;Three server-side tools do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;update_artifact&lt;/strong&gt; — incrementally builds the step's structured output as the conversation progresses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;complete_step&lt;/strong&gt; — finalizes the artifact, captures analysis and summary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;send_report&lt;/strong&gt; — collects all completed artifacts, generates a consolidated PDF, delivers via email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The artifact panel shows the structured output updating in real time as the conversation progresses — the founder sees their canvas or competitive matrix forming, not just chat bubbles.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Context Moves Between Tools
&lt;/h2&gt;

&lt;p&gt;Each tool produces a typed artifact. The Business Model Canvas produces an object with &lt;code&gt;key_partners&lt;/code&gt;, &lt;code&gt;revenue_streams&lt;/code&gt;, &lt;code&gt;cost_structure&lt;/code&gt;. User Personas produces an array of persona objects. Feature Prioritization produces a classification map.&lt;/p&gt;

&lt;p&gt;When a founder continues to the next tool, those artifacts get injected into the new tool's system prompt as structured JSON. Chat history doesn't cross tool boundaries — the back-and-forth of step one is noise inside step six. What crosses is the concluded output.&lt;/p&gt;

&lt;p&gt;Each tool ends with two inline options rendered as suggestion pills on the last AI message: &lt;strong&gt;Continue to [next tool]&lt;/strong&gt; or &lt;strong&gt;Send report via email&lt;/strong&gt;. If the founder requests the report, all completed artifacts get compiled into a PDF and delivered to their inbox. If they continue, the next tool opens with context already loaded. Both outcomes are first-class. Stopping after step two means you have a competitive analysis report — that's a complete deliverable, not an abandoned session.&lt;/p&gt;

&lt;p&gt;Email capture happens at the moment a founder requests their report — after they've gotten value, not before they've seen anything. That single change converted capture from a gate into an offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Engineering That Wasn't
&lt;/h2&gt;

&lt;p&gt;Early in the build, I added a line to each tool's system prompt: "Use prior context if available to inform your analysis." Seemed reasonable.&lt;/p&gt;

&lt;p&gt;It didn't work. The model would occasionally reference something from an earlier step, but inconsistently and shallowly. Feature Prioritization wasn't connecting domain classifications to the Tech Strategy decisions that depended on them. I spent two hours trying different phrasings before accepting the problem wasn't the wording.&lt;/p&gt;

&lt;p&gt;The fix was specificity. Not "use prior context" — enumerate every upstream artifact by name, every relevant field, and exactly how it should influence the current step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Prior Step Context&lt;/span&gt;

If the following steps are complete, use their outputs as described:
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Feature Prioritization**&lt;/span&gt; — use &lt;span class="sb"&gt;`domain_classification`&lt;/span&gt; (core / supporting / generic)
  to anchor build-vs-buy decisions. Core = build custom. Generic = always buy.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**User Personas**&lt;/span&gt; — use &lt;span class="sb"&gt;`technical_proficiency`&lt;/span&gt; and &lt;span class="sb"&gt;`platform_preferences`&lt;/span&gt;
  to shape deployment and integration decisions.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Market Sizing**&lt;/span&gt; — use TAM/SAM/SOM scale to calibrate infrastructure complexity.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model follows explicit field references. It ignores vague instructions to "use context." The more precisely you enumerate the step name, the field name, and how to apply it — the more consistently the output reflects what prior steps actually found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Around the Deliverable
&lt;/h2&gt;

&lt;p&gt;The session format is an inherited assumption from chat UIs. It made sense for general-purpose assistants. It doesn't make sense for a process that unfolds across days or weeks, where each stage has its own mental context and its own moment of urgency.&lt;/p&gt;

&lt;p&gt;Decomposing the monolithic tool changed everything downstream. Eight tools means eight landing pages means eight keywords. Each tool is a complete product for someone who needs just that one thing. The full journey still exists for founders who want it — they just don't have to commit to it upfront.&lt;/p&gt;

&lt;p&gt;If your AI tool covers something that spans multiple sittings and mental states, the deliverable is the right unit to build around. Not the conversation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live: &lt;a href="https://varstatt.com/discovery" rel="noopener noreferrer"&gt;varstatt.com/discovery&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
    </item>
    <item>
      <title>The Production Bugs That Never Threw an Error</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/the-production-bugs-that-never-threw-an-error-4g64</link>
      <guid>https://forem.com/jurijtokarski/the-production-bugs-that-never-threw-an-error-4g64</guid>
      <description>&lt;p&gt;Six production failures. Every log said success. The API returned 200. The job exited clean. Each one cost real time — not because the bug was hard to find once I knew where to look, but because the system accepted the input, confirmed receipt, and executed something different from what I intended. No exception. No warning. Just a quietly wrong outcome at the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OAuth Token That Baked In the Past
&lt;/h2&gt;

&lt;p&gt;A content automation script returned a 403 from the Twitter v2 API. The message: &lt;code&gt;Your client app is not configured with the appropriate oauth1 app permissions for this endpoint.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I'd already upgraded the app from Read to Read+Write in the Developer Portal. The settings showed the correct value. The error said otherwise.&lt;/p&gt;

&lt;p&gt;OAuth 1.0a tokens carry the permission scope active at the moment they were generated. Changing the app's permissions afterward does nothing to existing tokens — they permanently hold the scope they were issued with. The Developer Portal shows you a clean green state with no indication that your tokens are now stale relative to your updated settings. The 403 message says "app configuration," which points you at the thing you already fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; After any permission change on Twitter, regenerate the Access Token and Access Token Secret immediately. Don't test anything first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BTW:&lt;/strong&gt; Since X moved to pay-per-use pricing in early 2026, the same 403 with the same "oauth1 app permissions" message can also mean your account has no active billing. The Free tier officially supports POST /2/tweets, but developers report it returning 403s intermittently with no configuration changes on their end (&lt;a href="https://devcommunity.x.com/t/403-forbidden-on-post-2-tweets-read-and-write-scopes-ignored-on-free-plan/251574" rel="noopener noreferrer"&gt;1&lt;/a&gt;, &lt;a href="https://devcommunity.x.com/t/unable-to-post-tweet-through-api-403-forbidden-you-are-not-permitted-to-perform-this-action/229413" rel="noopener noreferrer"&gt;2&lt;/a&gt;, &lt;a href="https://devcommunity.x.com/t/post-on-free-tier/241130" rel="noopener noreferrer"&gt;3&lt;/a&gt;). If you've regenerated tokens and the error persists, check whether your account needs a paid plan or a billing top-up before debugging further.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cache That Survived the Uninstall
&lt;/h2&gt;

&lt;p&gt;Removed &lt;code&gt;@sentry/nextjs&lt;/code&gt; from a project. Pulled it from &lt;code&gt;package.json&lt;/code&gt;, ran install, cleaned up the config. Next &lt;code&gt;dev&lt;/code&gt; run threw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Cannot find module '@sentry/nextjs'
Require stack:
- .next/server/instrumentation.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package wasn't in &lt;code&gt;node_modules&lt;/code&gt;. Wasn't in &lt;code&gt;package.json&lt;/code&gt;. Nowhere. But Next.js kept looking for it.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;.next/server/&lt;/code&gt; was a compiled &lt;code&gt;instrumentation.js&lt;/code&gt; from a previous build — one Sentry had hooked into during installation. The incremental build never touched that file because I hadn't changed the instrumentation source, only the package. It just sat there, referencing something that no longer existed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; .next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;yarn dev&lt;/code&gt;. No errors. Thirty seconds of actual work after ten minutes of confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Removing a plugin that hooks into Next.js instrumentation means deleting &lt;code&gt;.next&lt;/code&gt; as part of the removal. Not after the next error — as part of the removal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Job That Reported Success While Running Nothing
&lt;/h2&gt;

&lt;p&gt;A scheduled launchd job on macOS. The plist was configured, the wrapper script pointed at the right Node script, everything looked right. launchd reported &lt;code&gt;completed successfully&lt;/code&gt; on every run. Nothing was being posted.&lt;/p&gt;

&lt;p&gt;I added logging, ran it manually. The log showed the Node process starting, then silence. Ran it directly from the terminal — it worked fine.&lt;/p&gt;

&lt;p&gt;With verbose output piped to a log file, the job finally showed something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: spawn claude ENOENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;claude&lt;/code&gt; binary lives at &lt;code&gt;~/.local/bin/claude&lt;/code&gt;. My terminal knows that because my shell config adds that path. launchd doesn't. It starts processes with a stripped-down environment — no user shell, no &lt;code&gt;~/.local/bin&lt;/code&gt;, nothing accumulated over years of machine setup. The Node script was swallowing the subprocess error and exiting 0 regardless. launchd saw a clean exit and called it a success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; One line in the wrapper script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Users/jurijtokarski/.local/bin:/opt/homebrew/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Use absolute paths in launchd plists. Test jobs with the same stripped environment launchd uses — not from your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Node That Activated Fine, Then Didn't
&lt;/h2&gt;

&lt;p&gt;Added an IF node to an n8n workflow to branch between two processing paths. Saved cleanly. Validated cleanly. The editor showed no warnings. Activated the workflow and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot read properties of undefined (reading 'execute')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No node name. No stack trace. Nothing pointing anywhere useful.&lt;/p&gt;

&lt;p&gt;I checked the Code nodes. Checked the Merge node. Checked the connections. The IF node wasn't even on my radar — it had saved without complaint. Eventually I pulled the raw workflow JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-nodes-base.if"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The n8n instance didn't have &lt;code&gt;typeVersion: 2.3&lt;/code&gt; of the IF node. The editor accepted it — it doesn't validate typeVersion against what's installed on the runtime. The execution engine hit an undefined handler and threw.&lt;/p&gt;

&lt;p&gt;Downgrading to &lt;code&gt;2.2&lt;/code&gt; fixed it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; The n8n editor and the n8n runtime have different views of what's valid. When an activation error is opaque and traceless, check &lt;code&gt;typeVersion&lt;/code&gt; before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stream That Delivered No Audio
&lt;/h2&gt;

&lt;p&gt;Building a screen and audio capture feature. Every API call succeeded. Production recordings came back with only the microphone — no shared app audio. No error in the console. &lt;code&gt;getDisplayMedia&lt;/code&gt; had resolved cleanly, the stream object was there, the video track was present.&lt;/p&gt;

&lt;p&gt;I spent a while assuming the AudioContext mixing was wrong before checking something obvious:&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;stream&lt;/span&gt; &lt;span class="o"&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;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDisplayMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;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;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero audio tracks. The user had gone through the picker and selected a tab without checking the "Share audio" checkbox. The browser doesn't reject the promise in that case. No warning, no error, no indication the audio side of the request was skipped. The spec gives you an empty array and moves on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Check &lt;code&gt;audioTracks.length&lt;/code&gt; immediately after resolution. If it's zero, surface an explicit re-prompt before proceeding. A resolved &lt;code&gt;getDisplayMedia&lt;/code&gt; call is not a guarantee that you got what you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sub-Agent Searching the Wrong Store
&lt;/h2&gt;

&lt;p&gt;A multi-step analysis pipeline: an orchestrator that reads documents, spawns specialist agents to evaluate content, streams structured results back to the UI. The orchestrator chains turns via &lt;code&gt;previous_response_id&lt;/code&gt;. Sub-agents were supposed to be isolated, stateless calls.&lt;/p&gt;

&lt;p&gt;At one step, agent responses were coherent but consistently wrong. Clean outputs, plausible reasoning, wrong knowledge base.&lt;/p&gt;

&lt;p&gt;What &lt;code&gt;previous_response_id&lt;/code&gt; carries isn't just conversation history — it inherits the full tool configuration of the parent response, including attached &lt;code&gt;file_search&lt;/code&gt; vector stores. The orchestrator had a tender documents store bound to it. Every chained orchestrator call accumulated that binding. When the orchestrator's final response ID was passed to a specialist agent — one explicitly configured with a completely different store — the API silently merged the orchestrator's tool configuration in. The agent queried the wrong store. No error. No warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Agent calls are stateless. They have no legitimate reason to continue a conversation chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEPLOYMENT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;previous_response_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previousResponseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After — agents never inherit the orchestrator's chain&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEPLOYMENT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Any sub-agent that needs isolated tools must be a fresh, stateless request with no chain ID. Explicitly configuring different tools does not override what the chain carries in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What These Six Have in Common
&lt;/h2&gt;

&lt;p&gt;None of them failed at the point of input. The token was accepted. The build succeeded. The job exited. The editor saved the node. The stream resolved. The agent returned a clean response. Every failure happened at the output — in the actual result, not the API boundary.&lt;/p&gt;

&lt;p&gt;The gap between "I accepted your input" and "the right thing occurred" is where these live. The fix isn't adding more logging to the call sites. It's verifying at the output layer: check &lt;code&gt;audioTracks.length&lt;/code&gt; after resolution, not before. Pull the raw JSON of a node that failed at activation. Log &lt;code&gt;err.data&lt;/code&gt; on a 403, not just &lt;code&gt;err.message&lt;/code&gt;. Check what the agent actually searched, not what you told it to search.&lt;/p&gt;

&lt;p&gt;Success at the API boundary tells you the system is running. It tells you nothing about what the system is doing.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>monitoring</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Using Firestore Transactions to Handle Race Conditions</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/using-firestore-transactions-to-handle-race-conditions-4o9c</link>
      <guid>https://forem.com/jurijtokarski/using-firestore-transactions-to-handle-race-conditions-4o9c</guid>
      <description>&lt;p&gt;The system creates an OpenAI vector store per company — a dedicated knowledge base the AI secretary queries when answering questions. Creating it is expensive: an API call to Azure OpenAI, followed by writing the returned ID back to the company doc in Firestore.&lt;/p&gt;

&lt;p&gt;What I ran into: multiple cloud function instances can process triggers at the same time. If two invocations both check &lt;code&gt;workflow_2_vector_store_id&lt;/code&gt;, find it empty, and both proceed to create a vector store — you've just orphaned one. It sits there, billed by the token, never used.&lt;/p&gt;

&lt;p&gt;My first instinct was "just check before creating." That doesn't work — the check and the write are not atomic. Two instances read an empty field at the same millisecond, both proceed.&lt;/p&gt;

&lt;p&gt;What worked is a Firestore transaction as the gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patchCompanyWithVectorStoreIfMissing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;assistantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;wePatched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;runDBTransaction&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="nx"&gt;tx&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;snapshot&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DOC_COMPANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;companyId&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;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;workflow_2_vector_store_id&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;wePatched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DOC_COMPANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;workflow_2_vector_store_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;workflow_2_assistant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;assistantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;wePatched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both instances still create a vector store optimistically — that's unavoidable, the external API call can't be inside the transaction. But only one wins the write. The loser gets &lt;code&gt;wePatched: false&lt;/code&gt; and immediately fires cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wePatched&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;patchCompanyWithVectorStoreIfMissing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;wePatched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cleanupCompanyVectorStoreAndAssistant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vectorStoreId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transaction is the single source of truth for "has this resource been claimed?" Optimistic creation outside it is fine — as long as the loser always cleans up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>googlecloud</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Merging Two Firestore Listeners for Cross-Field OR Queries</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/jurijtokarski/merging-two-firestore-listeners-for-cross-field-or-queries-2292</link>
      <guid>https://forem.com/jurijtokarski/merging-two-firestore-listeners-for-cross-field-or-queries-2292</guid>
      <description>&lt;p&gt;The assignment feature needed a real-time subscription: show tenders where &lt;code&gt;creator_id == userId&lt;/code&gt; OR &lt;code&gt;assignee_ids&lt;/code&gt; array-contains &lt;code&gt;userId&lt;/code&gt;. A cross-field OR.&lt;/p&gt;

&lt;p&gt;Firestore's &lt;code&gt;Filter.or()&lt;/code&gt; works for same-field conditions. For fundamentally different field types — equality vs. array-contains — composite OR queries don't compose cleanly, and the SDK support varies by version. The alternative is a denormalised collection: fan out writes to a &lt;code&gt;user_tenders&lt;/code&gt; subcollection on every state change. That's a write-time tax and more surface area for issues down the line.&lt;/p&gt;

&lt;p&gt;The working approach: two separate &lt;code&gt;onSnapshot&lt;/code&gt; listeners, results merged client-side into a &lt;code&gt;Map&lt;/code&gt; keyed by document ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;firebaseCompanyTendersSubscribeByCreatorOrAssignee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;callback&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;merged&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;Map&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;unsubCreator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;COLLECTION_TENDERS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;creator_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&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;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;doc&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;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;docChanges&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doc&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;removed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubAssignee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;COLLECTION_TENDERS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assignee_ids&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;array-contains&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&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;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;doc&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;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;docChanges&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doc&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;removed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;unsubCreator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;unsubAssignee&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;Deduplication is automatic — same document ID overwrites itself in the Map. Either listener updating triggers a re-merge and emits a fresh array. The cleanup path requires calling both unsubscribes; returning just one leaks the other listener.&lt;/p&gt;

&lt;p&gt;No fan-out collection. No schema changes. The subscription logic stays in one place.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
