<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jurij Tokarski</title>
    <description>The latest articles on DEV Community by Jurij Tokarski (@jurijtokarski).</description>
    <link>https://dev.to/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>DEV Community: Jurij Tokarski</title>
      <link>https://dev.to/jurijtokarski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jurijtokarski"/>
    <language>en</language>
    <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://dev.to/jurijtokarski/45-tabs-i-stopped-opening-34n5</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/npm-publish-without-tokens-4692</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/three-ways-the-wrong-value-won-49o6</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/an-empty-ai-response-corrupted-chat-history-1ap</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/software-engineering-principles-for-startups-3215</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/why-scrum-fails-in-small-teams-25a6</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/three-bugs-that-were-actually-my-prompts-3bc7</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/nobody-finishes-a-15-minute-ai-interview-2paf</link>
      <guid>https://dev.to/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://dev.to/jurijtokarski/the-production-bugs-that-never-threw-an-error-4g64</link>
      <guid>https://dev.to/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>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://dev.to/jurijtokarski/merging-two-firestore-listeners-for-cross-field-or-queries-2292</link>
      <guid>https://dev.to/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>
    <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://dev.to/jurijtokarski/using-firestore-transactions-to-handle-race-conditions-4o9c</link>
      <guid>https://dev.to/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>SwiftUI Is Like React + CSS-in-JS</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/swiftui-is-like-react-css-in-js-m17</link>
      <guid>https://dev.to/jurijtokarski/swiftui-is-like-react-css-in-js-m17</guid>
      <description>&lt;p&gt;A client brought me into an iOS project mid-stream. The app was already built — screens, navigation, state management, the works — and I needed to understand it fast enough to contribute meaningfully.&lt;/p&gt;

&lt;p&gt;The codebase was SwiftUI. I had zero SwiftUI experience.&lt;/p&gt;

&lt;p&gt;My usual move in this situation is to find the mental model that bridges the gap. I've been doing web development long enough that React, CSS, and async JavaScript are second nature. So instead of starting from scratch, I used Claude Code to explore the codebase and kept asking one question: &lt;em&gt;what's the web equivalent of this?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After a few hours of that, something clicked. SwiftUI is not exotic. If you know React, you already know 80% of the concepts — just with different syntax and a few iOS-specific primitives layered on top.&lt;/p&gt;

&lt;p&gt;This post is the reference I built during that session. I stripped the client-specific code and kept the patterns that apply universally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Concept: SwiftUI = React + CSS-in-JS + Declarative UI
&lt;/h2&gt;

&lt;p&gt;SwiftUI is declarative and component-based, just like React. Instead of JSX you write Swift, but the mental model is the same: describe what the UI should look like, and the framework figures out how to render it.&lt;/p&gt;

&lt;p&gt;The three things that felt foreign to me at first — and clicked once I found the right analogy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;No CSS files.&lt;/strong&gt; Styles live directly on the component as chained modifiers. Think CSS-in-JS but without the library — it's just how the language works.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;No DOM.&lt;/strong&gt; SwiftUI renders to native UIKit controls under the hood. You never touch them directly, just like you never touch the DOM in React.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;State drives everything.&lt;/strong&gt; Change a &lt;code&gt;@State&lt;/code&gt; variable and the view re-renders. Same contract as &lt;code&gt;useState&lt;/code&gt;, same mental model, different syntax.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Components — Structs that return a view, not JSX
&lt;/h2&gt;

&lt;p&gt;In React a component is a function that returns JSX. In SwiftUI it's a &lt;code&gt;struct&lt;/code&gt; that conforms to the &lt;code&gt;View&lt;/code&gt; protocol and implements a &lt;code&gt;body&lt;/code&gt; property. The props become &lt;code&gt;let&lt;/code&gt; constants declared directly on the struct, and instead of a &lt;code&gt;return&lt;/code&gt; statement you describe the layout inline.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NoteCard&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="nx"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;dateLabel&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subtitle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;dateLabel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;NoteCardView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;dateLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;VStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nv"&gt;alignment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;leading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nv"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dateLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;background&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"NeutralCool0"&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;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;struct&lt;/code&gt; instead of &lt;code&gt;function&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;var body: some View&lt;/code&gt; instead of &lt;code&gt;return&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Swift types (&lt;code&gt;String?&lt;/code&gt;, &lt;code&gt;Int&lt;/code&gt;) instead of JavaScript types&lt;/li&gt;
&lt;li&gt;  Modifiers (&lt;code&gt;.padding()&lt;/code&gt;, &lt;code&gt;.background()&lt;/code&gt;) instead of CSS classes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Layout — Named containers instead of flex properties
&lt;/h2&gt;

&lt;p&gt;On the web layout is a property you set on an element — &lt;code&gt;display: flex&lt;/code&gt;, &lt;code&gt;flex-direction: row&lt;/code&gt;. In SwiftUI layout is structural: you pick a container (&lt;code&gt;VStack&lt;/code&gt;, &lt;code&gt;HStack&lt;/code&gt;, &lt;code&gt;ZStack&lt;/code&gt;) and nest views inside it. There are no flex properties to remember because the direction is baked into the container name.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;space-between&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// like flex-direction: column&lt;/span&gt;
&lt;span class="kt"&gt;VStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;spacing&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="c1"&gt;// like flex-direction: row&lt;/span&gt;
    &lt;span class="kt"&gt;HStack&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"My App"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kt"&gt;Spacer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// like flex-grow: 1&lt;/span&gt;
        &lt;span class="kt"&gt;Button&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="nv"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;horizontal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&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;Layout containers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;VStack&lt;/code&gt; → &lt;code&gt;display: flex; flex-direction: column&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;HStack&lt;/code&gt; → &lt;code&gt;display: flex; flex-direction: row&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;ZStack&lt;/code&gt; → &lt;code&gt;position: relative&lt;/code&gt; + absolute children&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;Spacer()&lt;/code&gt; → &lt;code&gt;flex-grow: 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;LazyVGrid&lt;/code&gt; → CSS Grid&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Styling — Modifiers chain where CSS classes would go
&lt;/h2&gt;

&lt;p&gt;There are no class names, no stylesheets, no CSS-in-JS library. Every visual property is a modifier method chained directly onto the view. Order matters — &lt;code&gt;.padding()&lt;/code&gt; before &lt;code&gt;.background()&lt;/code&gt; gives a different result than after it, just like stacking CSS properties in a specific order can change rendering.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.06&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;VStack&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;background&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"NeutralCool0"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cornerRadius&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;black&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;x&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="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Common modifiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;.padding(16)&lt;/code&gt; → &lt;code&gt;padding: 16px&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.padding(.horizontal, 20)&lt;/code&gt; → &lt;code&gt;padding-left: 20px; padding-right: 20px&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.background(Color.red)&lt;/code&gt; → &lt;code&gt;background-color: red&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.foregroundColor(.blue)&lt;/code&gt; → &lt;code&gt;color: blue&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.font(.system(size: 17))&lt;/code&gt; → &lt;code&gt;font-size: 17px&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.fontWeight(.medium)&lt;/code&gt; → &lt;code&gt;font-weight: 500&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.cornerRadius(12)&lt;/code&gt; → &lt;code&gt;border-radius: 12px&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.frame(width: 100, height: 50)&lt;/code&gt; → &lt;code&gt;width: 100px; height: 50px&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.frame(maxWidth: .infinity)&lt;/code&gt; → &lt;code&gt;width: 100%&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.shadow(...)&lt;/code&gt; → &lt;code&gt;box-shadow: ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.overlay(...)&lt;/code&gt; → &lt;code&gt;::before&lt;/code&gt; or &lt;code&gt;::after&lt;/code&gt; pseudo-element&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. State Management — Property wrappers instead of hooks
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@State&lt;/code&gt; is &lt;code&gt;useState&lt;/code&gt;. The syntax is different but the contract is identical: declare a variable, mutate it, the view re-renders. What takes more time to learn is the broader family of property wrappers — SwiftUI has several, each with a specific purpose, where React hooks tend to blur together.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Counter&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
       &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;Increment&lt;/span&gt;

      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;CounterView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@State&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kd"&gt;@State&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;VStack&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="kt"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Increment"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;count&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;loading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;ProgressView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;State property wrappers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;@State&lt;/code&gt; → &lt;code&gt;useState()&lt;/code&gt; — local component state&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;@StateObject&lt;/code&gt; → &lt;code&gt;useState()&lt;/code&gt; with object — owns an ObservableObject&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;@ObservedObject&lt;/code&gt; → props (object) — observes external ObservableObject&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;@Published&lt;/code&gt; → state in class component — triggers UI updates&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;@Binding&lt;/code&gt; → props callback — two-way data binding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For shared state, &lt;code&gt;ObservableObject&lt;/code&gt; fills the same role as React Context or Redux. You mark properties with &lt;code&gt;@Published&lt;/code&gt; and the views that observe it re-render automatically — no dispatch, no selectors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;AppViewModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Item&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="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;loadItems&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="n"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchItems&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
        &lt;span class="n"&gt;isLoading&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Conditional Rendering — Plain if/else, no ternary tricks
&lt;/h2&gt;

&lt;p&gt;React leans on ternaries and &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; because JSX is an expression — it has to return something. SwiftUI uses plain &lt;code&gt;if/else&lt;/code&gt; blocks because the body is just code, not an expression. The result is actually easier to read once you have three or four states to handle, since you're not nesting ternaries.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ProgressView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Loading..."&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="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ErrorView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Nothing here yet"&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="kt"&gt;ScrollView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;ForEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;ItemCardView&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="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;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  No ternary nesting — &lt;code&gt;if/else&lt;/code&gt; reads linearly&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;if let&lt;/code&gt; unwraps optionals inline — common Swift pattern&lt;/li&gt;
&lt;li&gt;  Empty state (&lt;code&gt;items.isEmpty&lt;/code&gt;) fits naturally as another branch&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Lists — ForEach maps items to views
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ForEach&lt;/code&gt; is &lt;code&gt;Array.map()&lt;/code&gt;. The one gotcha: items need to conform to the &lt;code&gt;Identifiable&lt;/code&gt; protocol, which is SwiftUI's version of React's &lt;code&gt;key&lt;/code&gt; prop — it needs a stable identity to track which items changed between renders. In practice this usually means your model has an &lt;code&gt;id&lt;/code&gt; property, which it probably already does.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;ForEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="kt"&gt;ItemCardView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;formattedDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdAt&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;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;ForEach&lt;/code&gt; replaces &lt;code&gt;.map()&lt;/code&gt; — no &lt;code&gt;key&lt;/code&gt; prop needed if item conforms to &lt;code&gt;Identifiable&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  For grids, swap &lt;code&gt;VStack&lt;/code&gt; for &lt;code&gt;LazyVGrid&lt;/code&gt; — CSS Grid with lazy loading built in&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;LazyVGrid&lt;/code&gt; takes an array of &lt;code&gt;GridItem&lt;/code&gt; values to define columns&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Event Handlers — Actions as trailing closures
&lt;/h2&gt;

&lt;p&gt;Web events are attributes you attach to elements. In SwiftUI interactive views like &lt;code&gt;Button&lt;/code&gt; take an action closure as their first argument. For non-interactive views you attach &lt;code&gt;.onTapGesture&lt;/code&gt;, &lt;code&gt;.onAppear&lt;/code&gt;, or &lt;code&gt;.onDisappear&lt;/code&gt; as modifiers — the same way you'd add &lt;code&gt;onClick&lt;/code&gt; to a &lt;code&gt;div&lt;/code&gt; in React.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;Click&lt;/span&gt; &lt;span class="nx"&gt;me&lt;/span&gt;


 &lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&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="sr"&gt;/&amp;amp;gt&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Click me"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// $ = two-way binding&lt;/span&gt;
&lt;span class="kt"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Enter text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;$textValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Event modifiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;Button { action }&lt;/code&gt; → &lt;code&gt;&amp;lt;button onClick={action}&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.onTapGesture { }&lt;/code&gt; → &lt;code&gt;onClick&lt;/code&gt; on any element&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.onAppear { }&lt;/code&gt; → &lt;code&gt;useEffect&lt;/code&gt; on mount&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.onDisappear { }&lt;/code&gt; → &lt;code&gt;useEffect&lt;/code&gt; cleanup on unmount&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;TextField(..., text: $value)&lt;/code&gt; → &lt;code&gt;&amp;lt;input onChange={...}&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Design Tokens — Color assets instead of CSS variables
&lt;/h2&gt;

&lt;p&gt;CSS variables live in a stylesheet. SwiftUI color tokens live in &lt;code&gt;Assets.xcassets&lt;/code&gt; — a file managed through Xcode's visual editor, not in code. You reference them by name string, which feels fragile at first, but in practice it works the same way. For component variants, Swift enums replace CSS class modifiers cleanly.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--primary-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#00a86b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--primary-color&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Colors live in Assets.xcassets&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;background&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PrimaryGreen600"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;foregroundColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"NeutralCool800"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Colors defined in &lt;code&gt;Assets.xcassets&lt;/code&gt;, referenced by name string&lt;/li&gt;
&lt;li&gt;  No cascade — colors don't inherit down the tree unless you set them explicitly&lt;/li&gt;
&lt;li&gt;  Enums replace CSS class variants for component styles:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;ButtonVariant&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;secondary&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PrimaryGreen600"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&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;h2&gt;
  
  
  9. Async Operations — Task instead of useEffect
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;async/await&lt;/code&gt; syntax is nearly identical to JavaScript — this was the one area where SwiftUI felt immediately familiar. The difference is how you trigger async work from a view: instead of &lt;code&gt;useEffect&lt;/code&gt;, you use &lt;code&gt;.onAppear&lt;/code&gt; combined with &lt;code&gt;Task { }&lt;/code&gt;. &lt;code&gt;Task&lt;/code&gt; creates a new async context from synchronous code, the same way you'd call an async IIFE in JavaScript.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&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;loadData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setLoading&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setItems&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setLoading&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="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;loadData&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;loadItems&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="n"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchItems&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;isLoading&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="n"&gt;onAppear&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadItems&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;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;.onAppear&lt;/code&gt; + &lt;code&gt;Task { }&lt;/code&gt; replaces &lt;code&gt;useEffect&lt;/code&gt; on mount&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;do/catch&lt;/code&gt; replaces &lt;code&gt;try/catch&lt;/code&gt; — same idea, slightly different syntax&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;@MainActor&lt;/code&gt; on the ViewModel ensures UI updates happen on the main thread, same reason React's state setters are synchronous&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Navigation — NavigationStack wraps the whole screen tree
&lt;/h2&gt;

&lt;p&gt;React Router lives outside your component tree and you declare routes centrally. SwiftUI's &lt;code&gt;NavigationStack&lt;/code&gt; wraps directly around the content — you place it in the view hierarchy and &lt;code&gt;NavigationLink&lt;/code&gt; handles the push transition. It's more like Next.js file-based routing in feel: navigation is co-located with the UI that triggers it.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;

    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;amp;gt&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="sr"&gt;/&amp;amp;gt&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// like &lt;/span&gt;
&lt;span class="kt"&gt;NavigationStack&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ScrollView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;ForEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="c1"&gt;// like &lt;/span&gt;
            &lt;span class="kt"&gt;NavigationLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ItemDetailView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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="kt"&gt;ItemCardView&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="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;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  No central route config — destination is declared at the link site&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;NavigationStack&lt;/code&gt; manages the back stack automatically&lt;/li&gt;
&lt;li&gt;  Wrap the root view once; all nested &lt;code&gt;NavigationLink&lt;/code&gt;s push onto the same stack&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  11. Responsive Design — Device checks instead of breakpoints
&lt;/h2&gt;

&lt;p&gt;CSS media queries respond to viewport width. SwiftUI doesn't have a viewport — it has devices. The idiomatic approach is a simple device type check (&lt;code&gt;UIDevice.current.userInterfaceIdiom&lt;/code&gt;) and a ternary at the modifier level. It's more explicit than media queries but also simpler: iPad or not iPad covers most cases.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;768px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isIPad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;UIDevice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userInterfaceIdiom&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pad&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;VStack&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;horizontal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isIPad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isIPad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;69&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&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;Key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  No breakpoints — device type replaces viewport width&lt;/li&gt;
&lt;li&gt;  Ternary inline on each modifier instead of a separate media block&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;SizeClass&lt;/code&gt; environment variable available for more nuanced layouts&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The syntax is new. The concepts are not. Every pattern here has a direct equivalent you already know — and once that clicks, reading an unfamiliar SwiftUI codebase stops feeling like learning a new paradigm and starts feeling like reading familiar code in an accent you haven't heard before.&lt;/p&gt;

&lt;p&gt;The things that genuinely differ from web: no CSS files, no DOM, modifiers instead of class names, and &lt;code&gt;Identifiable&lt;/code&gt; instead of &lt;code&gt;key&lt;/code&gt;. Everything else maps cleanly.&lt;/p&gt;

&lt;p&gt;If you're jumping into an iOS codebase, start with the component structure and state — get those two right and the rest follows.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>react</category>
      <category>swift</category>
    </item>
  </channel>
</rss>
