<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mihai Marinescu's Blog]]></title><description><![CDATA[Everything JavaScript plus more. Frontend developer writing backend.]]></description><link>https://featuringcode.com</link><generator>RSS for Node</generator><lastBuildDate>Tue, 08 Sep 2026 16:03:19 GMT</lastBuildDate><atom:link href="https://featuringcode.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[fc-react-dnd]]></title><description><![CDATA[Glad to announce that I published my first npm library https://www.npmjs.com/package/fc-react-dnd and you can play with it here.
How this drag-and-drop library works, traced through one tree
Making a ]]></description><link>https://featuringcode.com/fc-react-dnd</link><guid isPermaLink="true">https://featuringcode.com/fc-react-dnd</guid><category><![CDATA[npm]]></category><category><![CDATA[DND]]></category><category><![CDATA[Tree]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 22 Aug 2026 19:57:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/a814880e-88b2-42e2-b6e4-337f1352a08f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Glad to announce that I published my first npm library <a href="https://www.npmjs.com/package/fc-react-dnd">https://www.npmjs.com/package/fc-react-dnd</a> and you can play with it <a href="https://fc-react-dnd-demo.vercel.app/">here</a>.</p>
<h1>How this drag-and-drop library works, traced through one tree</h1>
<p>Making a <code>&lt;div&gt;</code> draggable is solved everywhere. That is not the hard part.</p>
<p>The hard part is what happens <em>after you let go</em>.</p>
<p>Here is a document tree. Keep it in your head — the whole post follows this one tree, and every number below is traced from the real implementation, not invented for the example.</p>
<pre><code class="language-plaintext">Handbook          depth 0
  Onboarding      depth 1
  Engineering     depth 1
    Style guide   depth 2
    On-call       depth 2
Roadmap           depth 0
  Q3              depth 1
Meeting notes     depth 0
</code></pre>
<p>That indentation isn't decoration — it <em>is</em> the data. The tree is an array of nodes, and every node has the same shape: an <code>id</code>, and an optional <code>children</code> array of more nodes. (Your nodes carry their own fields too — a title, an icon — but the library only ever reads <code>id</code> and <code>children</code>.)</p>
<pre><code class="language-ts">type TreeItem = { id: string; children?: TreeItem[] }
</code></pre>
<p>So the outline above is really this:</p>
<pre><code class="language-ts">const tree = [
  { id: 'Handbook', children: [
    { id: 'Onboarding' },
    { id: 'Engineering', children: [
      { id: 'Style guide' },
      { id: 'On-call' },
    ] },
  ] },
  { id: 'Roadmap', children: [{ id: 'Q3' }] },
  { id: 'Meeting notes' },
]
</code></pre>
<p>Every step of indentation on the left is one more <code>children</code> array deep on the right. And that is the only address a node ever has: <strong>walk down</strong> <code>children</code> <strong>by index.</strong> <code>On-call</code> is <code>tree[0].children[1].children[1]</code> — the root's <em>second</em> child (<code>Engineering</code>), then <em>its</em> second child (<code>On-call</code>). Read any such path left to right: <code>item.children[0].children[2].children[1]</code> means "first child, then its third child, then that one's second child." Each <code>[index]</code> picks one sibling inside one parent — so <em>how many</em> <code>children</code> hops you took is the node's <strong>depth</strong>, and the <em>last</em> index you picked is its <strong>index</strong> among its siblings. That depth-vs-index distinction is the one the whole library turns on; keep it in view.</p>
<p>Pick up <strong>Onboarding</strong>. Drag it down. Release it in the gap just under <strong>On-call</strong>.</p>
<p>Where did it go?</p>
<p>There are four honest answers, and they occupy the <strong>same pixels</strong>:</p>
<pre><code class="language-plaintext">    On-call (depth 2)
──────────────  ← you released here
    Roadmap (depth 0)
</code></pre>
<p>First child of On-call — depth 3.</p>
<p>Next sibling of On-call — depth 2.</p>
<p>Next sibling of Engineering — depth 1.</p>
<p>A new root document — depth 0.</p>
<p>Same gap. Same pixel row. Four different trees afterwards. The only thing separating them is how far <em>right</em> your cursor was.</p>
<p>That is the question this library answers. Not "what gesture did you make" but <em>where does the thing land.</em></p>
<hr />
<h2>1 · The state lives outside React</h2>
<p>Start with where the drag state is kept, because everything else follows from it.</p>
<p>It is a plain JavaScript object, in a plain closure. Not <code>useState</code>, not context. Its whole shape is four fields:</p>
<pre><code class="language-ts">type DragStoreState = {
  readonly origin: DragOrigin | null          // who you picked up — stable for the whole drag
  readonly overId: DndId | null               // an id, not an object
  readonly translate: Translate               // the only part that changes as you move
  readonly measuredRects: ReadonlyMap&lt;DndId, Rect&gt;
}
</code></pre>
<p>Created <strong>once per</strong> <code>&lt;DndProvider&gt;</code>, never at module scope. That is not fussiness: a module-level store is shared across requests on a server, and two providers on one page would fight over a single drag. Per-provider is SSR-safe and multi-provider-safe by construction.</p>
<p>React reads this object through <code>useSyncExternalStore</code> — the hook that exists precisely for state React does not own.</p>
<p>Now follow one <code>pointermove</code> from the browser to the pixel. This is the spine of the whole library:</p>
<pre><code class="language-plaintext">pointermove                          the browser, on the document
↓
sensor turns it into a translate     client code, reads no DOM
↓
store.move(translate)                updates the store's `translate` — a plain object, outside React
↓
collision against CACHED rects       arithmetic — no getBoundingClientRect
↓
a brand-new immutable state object   minted on every move
↓
subscribers are notified             every mounted row gave the store a callback — each is called
↓
each subscriber's selector runs, its result is compared
↓
only the slices that CHANGED reach React
</code></pre>
<p>Two of those steps do the real work.</p>
<p><strong>Rects are measured once.</strong> When the drag begins, every droppable's rectangle is read in one batched pass and cached in <code>measuredRects</code>. After that, every move is <em>arithmetic</em> against the cache — the active rect is <code>origin.rect + translate</code>, and collision compares numbers. Nothing calls <code>getBoundingClientRect</code> in the move path. Reading layout mid-move — read, write, read, write — is the classic drag-and-drop performance trap; this library reads once and never again, until a scroll or resize marks the cache dirty and the next move re-measures lazily.</p>
<p><strong>A new state object is minted every move.</strong> The store never mutates <code>state</code>; it replaces it wholesale:</p>
<pre><code class="language-ts">state = {
  origin,
  overId: detectOver(origin, translate, measuredRects),
  translate,
  measuredRects,
}
</code></pre>
<p>That immutability looks optional but it's actually essential. When I talk below about re-renders, you will find out why.</p>
<p>So the answer to "where does the work run" is: all of it on the client, none of it in React's render loop. React is a <em>reader</em> of a store it does not drive.</p>
<hr />
<h2>2 · Turning a gesture into a position</h2>
<p>A flat sortable list has an easy mapping: dropping "on item 3" means item 3 is an element, it has a rectangle, the pointer is inside it. Geometry answers the whole question.</p>
<p>A tree breaks that. As the four answers above showed, several outcomes share the same pixels, told apart by horizontal position — which corresponds to no element's box. So a tree drop is not an element you hit — it is a <strong>position</strong> you compute. Picture the movement first. You pick up <strong>Onboarding</strong> and release it right below Engineering's children — under <strong>On-call</strong>, the last row of Engineering's subtree — but held at <strong>Engineering's own indent</strong>, not pushed deeper:</p>
<pre><code class="language-plaintext">Handbook
  Onboarding            ← picked up
  Engineering
    Style guide
    On-call
  ────────────          ← lands here: below Engineering's subtree, at Engineering's indent
Roadmap
Meeting notes
</code></pre>
<p>It comes to rest below Engineering's whole subtree, but at the <em>same level</em> as Engineering — so Onboarding becomes Engineering's <strong>sibling</strong>, not its child. That movement is exactly this position:</p>
<pre><code class="language-ts">{
  parentId: 'handbook',                        // depth 1 sits under a depth-0 parent
  index: 2,                                    // Engineering's next sibling — still a Handbook child
  depth: 1,
  mode: 'between',
  afterId: 'engineering', beforeId: null,      // neighbours, by id
  indicator: { rowId: 'on-call', edge: 'below', depth: 1 },   // draw below Engineering's subtree
}
</code></pre>
<p>That tuple is the thing this library returns and general-purpose libraries do not. It is built in three moves, all <strong>pure functions over plain data</strong> — no React, no DOM, testable under plain Node in milliseconds.</p>
<h3>Move 1 — flatten, and cycles disappear here</h3>
<p>You can't do geometry on a nested structure. The rows on screen are a flat list, so the math runs on a flat list. <code>flattenTree</code> turns the nested array into one row per visible line, each carrying its depth and its <strong>sibling index</strong>:</p>
<pre><code class="language-plaintext">screen row                                    sibling index
 1 │ Handbook          depth 0   parent null          index 0
 2 │   Onboarding      depth 1   parent handbook      index 0
 3 │   Engineering     depth 1   parent handbook      index 1
 4 │     Style guide   depth 2   parent engineering   index 0
 5 │     On-call       depth 2   parent engineering   index 1
 6 │ Roadmap           depth 0   parent null          index 1
 7 │   Q3              depth 1   parent roadmap       index 0
 8 │ Meeting notes     depth 0   parent null          index 2
</code></pre>
<p>The number on the left is the <strong>screen row</strong>; <code>index</code> on the right is the <strong>sibling index</strong> — and they are not the same. Meeting notes is <code>index 2</code> (the root's third child) but screen row <strong>8</strong>. Mixing the two up is a silent off-by-one; a <code>TreeRow</code> carries the sibling index, never a row number.</p>
<p>Now the elegant part. Drag <strong>Engineering</strong> onto its own child <strong>Style guide</strong>. That must be impossible — a node cannot become its own grandparent.</p>
<p>The bad implementation is: compute the drop, then check for a cycle, then reject it. Drag all the way there, get refused.</p>
<p>This library does something better. <code>flattenTree</code> takes the id you are dragging and <strong>leaves that node and its whole subtree out of the rows</strong>:</p>
<pre><code class="language-plaintext">Handbook          ← dragging Engineering
  Onboarding
                  ← Engineering, Style guide, On-call: not rows at all
Roadmap
  ...
</code></pre>
<p>Style guide is no longer a row. It can't be aimed at. "Inside your own subtree" is never a position the math can <em>produce</em>.</p>
<p>A cycle you cannot express beats a cycle you catch. (There is still a defensive guard in <code>applyTreeDrop</code> for a hand-built projection — but the library's own path has never reached it, because the illegal state is unreachable.)</p>
<h3>Move 2 — aim at a row, into or between</h3>
<p>Every visible row has a cached rectangle. A row that will accept children is cut into three bands:</p>
<pre><code class="language-plaintext">┌────────────────────────────┐
│  top 30%                   │  → the gap ABOVE this row     (between)
├────────────────────────────┤
│  middle 40%   Engineering  │  → INSIDE this row            (into)
├────────────────────────────┤
│  bottom 30%                │  → the gap BELOW this row     (between)
└────────────────────────────┘
</code></pre>
<p>That 30 / 40 / 30 split is a tunable option — 0.3 of the row's height to each outer band by default. A row that <em>refuses</em> children has no middle — two bands, split down the half. That one choice is <code>mode</code>: <strong>into</strong> a node, or <strong>between</strong> two of them.</p>
<h3>Move 3 — choose the depth, and let people un-nest</h3>
<p>You are in a gap. Which of the four answers did you mean? Your horizontal offset decides, in indent-sized steps (24 px by default):</p>
<pre><code class="language-plaintext">Style guide (depth 2)
On-call (depth 2)
────┬────────┬────────┬────────┬─────
    │        │        │        └─ depth 3 → first child of On-call
    │        │        └────────── depth 2 → sibling of On-call
    │        └─────────────────── depth 1 → sibling of Engineering
    └──────────────────────────── depth 0 → sibling of Handbook
Roadmap (depth 0)
</code></pre>
<p>Not every depth is legal in every gap, though — the four answers above are the <em>most</em> any gap ever offers, and some gaps allow fewer. To see which, first name the two rows the gap sits between: the one just <strong>above</strong> it is <code>rowAbove</code>, the one just <strong>below</strong> it is <code>rowBelow</code>. That is the whole definition — the names <em>are</em> what they mean.</p>
<pre><code class="language-plaintext">On-call     depth 2    ← rowAbove   (the row just above the gap)
──────────             ← the gap you're hovering in
Roadmap     depth 0    ← rowBelow   (the row just below the gap)
</code></pre>
<p>The legal depths for a gap form a range, <code>[minDepth … maxDepth]</code>, and <strong>both ends are read straight off those two rows:</strong></p>
<pre><code class="language-plaintext">maxDepth = rowAbove.depth + 1   →   On-call is depth 2, so 2 + 1 = 3   →  deepest:    become On-call's first child
minDepth = rowBelow.depth       →   Roadmap is depth 0, so         0   →  shallowest: sit beside Roadmap
</code></pre>
<ul>
<li><p><code>maxDepth</code> <strong>(deepest) =</strong> <code>rowAbove.depth + 1</code><strong>.</strong> The deepest this gap can reach is to make the dragged row the <strong>direct child</strong> of the row above — and a direct child sits exactly one level in, at <code>rowAbove.depth + 1</code>. It can't go deeper: <code>rowAbove.depth + 2</code> would be a <em>grandchild</em>, and a grandchild has to hang off one of <code>rowAbove</code>'s own children — but sitting in the gap right below <code>rowAbove</code>, there is no child row here to hang off yet. You can only nest <strong>one</strong> level into a row you can actually see, so a row's children always live at its depth + 1 — and that is the deepest this position offers. (To reach a grandchild you'd drop in a gap below one of those children, not below <code>rowAbove</code>.)</p>
</li>
<li><p><code>minDepth</code> <strong>(shallowest) =</strong> <code>rowBelow.depth</code><strong>.</strong> To land <em>right here</em> — immediately before <code>rowBelow</code> — you have to be at least as deep as <code>rowBelow</code>. Go shallower and you no longer belong beside it: you belong to an outer group, and you'd land <em>after</em> its whole subtree, not before it.</p>
</li>
</ul>
<p>So in <em>this</em> gap you may drop anywhere from depth 0 to depth 3 — exactly the four answers from the top of the post. And <code>minDepth</code> is <code>0</code> here only because Roadmap happens to be a root; in a gap where the row below is <em>nested</em>, <code>minDepth</code> is that row's depth, not <code>0</code> — which is where things get interesting.</p>
<p><strong>That</strong> <code>maxDepth</code> <strong>is the uncontroversial half — every tree implementation clamps it the same way.</strong> It also respects the "will you accept children?" rule: if <code>rowAbove</code> refuses children, there is nothing to nest into, so <code>maxDepth</code> collapses to <code>rowAbove</code>'s own depth (no <code>+ 1</code>).</p>
<p><code>minDepth</code> <strong>is the half where this library differs from most implementations, on purpose.</strong> In the On-call gap above, <code>rowBelow</code> (Roadmap) is a root, so <code>minDepth</code> was 0 — you could drop anywhere, out to a brand-new root. But in a gap where the row below is <em>nested</em>, the same rule (<code>minDepth = rowBelow.depth</code>) stops you from un-nesting. The next example shows the problem, and why this library does <em>not</em> use it.</p>
<p>Say you're dragging <strong>Onboarding</strong>, and you want to lift it out of Handbook to become its own top-level document — depth 0. You hold it near the top and pull left, into the gap between <strong>Handbook</strong> and <strong>Engineering</strong>:</p>
<pre><code class="language-plaintext">Handbook        depth 0     ← rowAbove  (the row above the gap)
─────────────               ← the gap you're pulling into
  Engineering   depth 1     ← rowBelow  (the row below the gap)
    Style guide depth 2
</code></pre>
<p>Trace that bad <code>minDepth</code> for this exact gap:</p>
<table>
<thead>
<tr>
<th></th>
<th>the bad rule</th>
<th>value in this gap</th>
</tr>
</thead>
<tbody><tr>
<td>maxDepth</td>
<td><code>rowAbove.depth + 1</code></td>
<td><code>0 + 1</code> = <strong>1</strong> (a child of Handbook)</td>
</tr>
<tr>
<td>minDepth</td>
<td><code>rowBelow.depth</code></td>
<td><strong>1</strong> (Engineering's level)</td>
</tr>
<tr>
<td>legal depths</td>
<td><code>[minDepth … maxDepth]</code></td>
<td><code>[1 … 1]</code> → <strong>only depth 1</strong></td>
</tr>
<tr>
<td>you asked for</td>
<td>your depth <code>1</code>, minus one step left</td>
<td><strong>0</strong>, clamped back up to <strong>1</strong></td>
</tr>
</tbody></table>
<p><code>minDepth</code> 1, <code>maxDepth</code> 1. <strong>The only legal depth is 1</strong> — a child of Handbook. Under this rule you can pull left as hard as you like; the <code>minDepth</code> is stuck at 1, depth 0 is never offered, and the 0 you asked for is clamped straight back to 1. Onboarding cannot leave Handbook from this gap.</p>
<p>With that bad <code>minDepth</code>, un-nesting works in exactly one place: the very last gap of the group — below On-call, above Roadmap — where <code>rowBelow</code> is finally a shallow row (Roadmap at depth 0) and drags the <code>minDepth</code> to 0. So you'd have to drag Onboarding all the way to the <em>bottom</em> of Handbook first, and only then left. Nobody does that. It feels like the tree is ignoring your hand — which is exactly why this library doesn't stop at that <code>minDepth</code>.</p>
<p><strong>What this library does instead: a deliberate pull to the left drops the</strong> <code>minDepth</code> <strong>all the way to the root.</strong></p>
<pre><code class="language-ts">const minDepth = isPullingLeft ? 0 : rowBelow.depth       // ← pulling left ⇒ minDepth drops to 0
const maxDepth = canNest(rowAbove, active) ? rowAbove.depth + 1 : rowAbove.depth
</code></pre>
<p>(<code>isPullingLeft</code> just means your cursor has moved left of where the row started — the same horizontal offset that already picks the depth.)</p>
<p>Back in that Handbook↔Engineering gap, pulling left now gives <code>[0 … 1]</code> instead of <code>[1 … 1]</code>. One indent-step left takes the requested depth to 0, and Onboarding <em>does</em> become a top-level document.</p>
<p>Where does it land? Not floating between Handbook's children — a row can't sit at root level in the <em>middle</em> of Handbook's subtree. It lands <strong>after Handbook's whole subtree</strong>, as the next root item. Pulling left slides the row down and out. Coming out of a group is a downward move.</p>
<p>And the asymmetry — <code>minDepth</code> 0 only when pulling left — is the whole design, not a hack. If the <code>minDepth</code> were <em>always</em> 0, you could never drop anything <em>into</em> a group: a row dragged in from elsewhere already asks for depth 0, so an always-0 <code>minDepth</code> would grant it and drop the row <em>past</em> the group instead of inside it. The leftward pull is the one signal that tells "put this inside" apart from "take this out."</p>
<p>The keyboard reuses all of it — ArrowLeft/ArrowRight produce the same horizontal <code>translate</code> a pointer does, so the same clamp runs. Nothing downstream can tell which sensor drove the drag.</p>
<h3>Applying the drop shares structure</h3>
<p><code>applyTreeDrop</code> returns a new tree, but <strong>nearly every node in it is the same object you passed in</strong>. Only two ancestor spines are rebuilt — root-to-where-it-left, root-to-where-it-landed. Everything else keeps its identity. A drop allocates about <code>2 × depth</code> nodes whether the tree has three nodes or ten thousand.</p>
<p>The bad version here is a deep clone — <code>structuredClone</code>, a JSON round-trip, any recursive copy. It is correct in every value and <em>new in every reference</em>, so a drop rebuilds the entire tree and every memoised row re-renders. It looks fine in review and stutters when the tree grows. The sharing is pinned by tests that assert untouched nodes come back <code>===</code> identical.</p>
<h3>Where the general libraries stop</h3>
<p>To be fair and precise: the ecosystem does <strong>not</strong> ignore trees. Atlassian's Pragmatic drag-and-drop ships a real tree hitbox with five instructions — <code>reorder-above</code>, <code>reorder-below</code>, <code>make-child</code>, <code>reparent</code>, <code>instruction-blocked</code>. It knows about nesting bands and indentation.</p>
<p>But it stops at the <strong>gesture relative to one row</strong>. The function you call for it, <code>attachInstruction</code>, runs on a single row's drop data: it looks at where the pointer sits <em>inside that one row</em> and tags it with one of those instructions (<code>make-child</code>, <code>reorder-above</code>, …). It can't hand back the parent id, the index, or a depth clamped against the neighbours, because it never sees the neighbours — the real position depends on three rows (<code>rowAbove</code>, <code>rowBelow</code>, and the one in your hand), and a hitbox scoped to a single element sees only that element. That boundary is where <strong>every</strong> general-purpose React DnD library stops: it gives you the gesture, and turning the gesture into a position is left to you, every time. This library is that step.</p>
<hr />
<h2>3 · Fewer re-renders than dnd-kit, and the map that makes it possible</h2>
<p>Now the performance claim, measured.</p>
<p>React <code>&lt;Profiler&gt;</code> around <strong>each row</strong>, in Chrome, on a page that renders the same 24-item list twice — once with each library, identical counters. The drag itself is scripted, not done by hand: the pointer events are created in code — a <code>pointerdown</code>, 40 <code>pointermove</code>s along a fixed path, a <code>pointerup</code> — and the <em>identical</em> sequence is dispatched to each list in turn. A hand cannot repeat a drag pixel-for-pixel; a script can, which is what makes the two sides comparable and the runs repeatable. The path drags item 1 down onto item 4: <strong>154 px, 3 boundary crossings, 40 pointer moves.</strong> Three runs, identical each time.</p>
<table>
<thead>
<tr>
<th>During the drag — the 40 moves before you let go</th>
<th>Rows that re-rendered</th>
<th>Total commits</th>
</tr>
</thead>
<tbody><tr>
<td><strong>fc-react-dnd</strong></td>
<td><strong>4</strong> of 24</td>
<td><strong>9</strong></td>
</tr>
<tr>
<td>dnd-kit 6.3.1 / sortable 10.0.0</td>
<td><strong>24</strong> of 24</td>
<td><strong>96</strong></td>
</tr>
</tbody></table>
<p>Four is the row in your hand plus the three it displaces. dnd-kit re-renders every row in the list on every change.</p>
<p>Then you let go — and the full list re-renders in both libraries, because the drop is your own state update: it fires <code>onSortEnd</code>, your <code>setState</code> replaces the array, and a list whose data just changed re-renders. That is why every row's counter ends at 1 or more even on the fc-react-dnd side. The drop was measured too, and the two sides are not equal even here:</p>
<table>
<thead>
<tr>
<th>On the drop — your own reorder <code>setState</code></th>
<th>Rows that re-rendered</th>
<th>Total commits</th>
</tr>
</thead>
<tbody><tr>
<td>fc-react-dnd</td>
<td>24 of 24</td>
<td>24 — one per row</td>
</tr>
<tr>
<td>dnd-kit</td>
<td>24 of 24</td>
<td>48 — two per row</td>
</tr>
</tbody></table>
<p>Counting <em>rows that re-rendered</em> is the measure that matters, because it is the architecture made visible: when the drag state changes, how many rows have to find out about it? Here, four. That is the whole claim, and the rest of this section is why it is four and not twenty-four.</p>
<h3>Why context re-renders everything</h3>
<p>This is not a knock on dnd-kit — it is a property of React.</p>
<p>Put drag state in context, and <strong>every consumer re-renders when the value changes.</strong> There is no "subscribe to just the slice I care about." <code>memo</code> does not help; context propagation goes straight through it. So when you cross a boundary and the "who is over what" value changes, every row that reads the context re-renders — and each then works out, inside its own render, whether <em>it</em> moved. Most find out they did not, after they already rendered.</p>
<p><code>useSyncExternalStore</code> is the escape — and since this post says "subscribe" and "subscriber" constantly, follow <strong>one row</strong> through the act, because it is concrete enough to show in full.</p>
<p>Row <code>item-7</code> renders and calls <code>useSortable({ id: 'item-7', … })</code>. Inside, that hook calls <code>useSyncExternalStore(store.subscribe, getSnapshot)</code> — and here is the entire <code>store.subscribe</code>, not an excerpt:</p>
<pre><code class="language-ts">const listeners = new Set&lt;() =&gt; void&gt;()
// ...
subscribe: (listener) =&gt; {
  listeners.add(listener)
  return () =&gt; listeners.delete(listener)
}
</code></pre>
<p>That is the entire act of subscribing. The store keeps a <code>Set</code> of callback functions; item-7's render adds one — a callback React itself hands in, whose meaning is "something may have changed, re-check your snapshot." The returned function removes it again, and React calls that when the row unmounts. Everywhere this post says <strong>"a subscribed row," it means exactly this: a row whose callback is currently in that</strong> <code>Set</code><strong>.</strong> Twenty-four rows mounted = twenty-four callbacks in the set.</p>
<p>Notifying is just as plain. After minting the new state, the store loops the set:</p>
<pre><code class="language-ts">for (const listener of [...listeners]) listener()
</code></pre>
<p>Each call sends React back to that row's <strong>selector</strong> — the second half of a subscription: a function from the whole store state to the small piece this row cares about, its <strong>slice</strong>. item-7's selector ignores almost everything and answers one question — <em>is a drag active, and how far am I displaced?</em></p>
<pre><code class="language-ts">;(state) =&gt; {
  if (state.origin === null) return NO_DRAG_SLICE   // no drag → one shared idle object
  // the dragged row follows the pointer; every other row reads its own
  // shift out of the projection
  return { translate: /* my shift */, isDragActive: true }
}
</code></pre>
<p>React runs the selector, compares the fresh slice with the previous one, and — this is the guarantee — <strong>if the slice is unchanged, nothing happens.</strong> No lane, no scheduling, no render. The comparison happens <em>before</em> React is involved. The whole mechanism is this:</p>
<pre><code class="language-ts">const next = select(state)
if (cached &amp;&amp; areEqual(cached.slice, next)) {
  cache.current = { state, slice: cached.slice, select }
  return cached.slice        // ← the SAME reference, on purpose
}
</code></pre>
<p>Returning the same reference is what makes React schedule nothing. (It is also <em>correctness</em>, not just speed: a selector that built a fresh object every call would look changed every time, React would call it again, and you'd have an infinite loop — exactly what React's DEV "getSnapshot should be cached" warning detects.)</p>
<h3>The cost you can't avoid, and the map that keeps it cheap</h3>
<p>First, name the thing being computed. On every move, something has to answer: <em>if you dropped right now, where would the dragged item land, and how far does every other row shift to make room?</em> Section 2 built exactly that answer for the tree — the position tuple with <code>parentId</code>, <code>index</code>, <code>depth</code>, and the indicator. The sortable list has its own version of the same answer: which rows translate up or down, and by how much. That per-move answer is called the <strong>projection</strong> — it projects what the drop <em>would</em> produce while you are still dragging, and it is what the indicator line and the sliding rows are drawn from. (The types are literally named for it: <code>TreeDropProjection</code>, <code>ListProjection</code>.)</p>
<p><strong>The goal: compute that projection once per move, no matter how many rows are listening.</strong></p>
<p>The previous subsection got the <em>renders</em> down to the four rows that move. What it cannot reduce is the <em>selector calls</em>: to decide that a row's slice did not change, React has to run that row's selector and compare — the comparison that prevents the render requires the call. So every subscriber's selector runs on <strong>every</strong> store notification. Twenty-four subscribed rows × 40 moves in the measured drag is 960 selector calls, almost all of which conclude "nothing changed, render nothing."</p>
<p>960 calls is fine while each one is a cheap read. But the projection is not cheap to <em>compute</em>: it reads every row's cached rect, sorts them along the axis, and builds the per-row shifts. That is O(N) work. If all 24 selectors computed it themselves, every move would repeat that work 24 times. So the projection must be computed <strong>once</strong>, stored where every selector can reach it, and the other 23 calls must <em>read</em> it instead of recompute it.</p>
<p>The storage for that is one module-level cache, the same shape in both projection files:</p>
<pre><code class="language-ts">const projectionCache = new WeakMap&lt;DragStoreState, WeakMap&lt;object, CacheEntry&gt;&gt;()
</code></pre>
<p>Two WeakMaps, one inside the other. Build it up from what it has to do.</p>
<p><strong>How a selector knows "this move already has a projection": the state object's identity.</strong> Section 1 set this up — the store never mutates its state; every move mints a brand-new <code>DragStoreState</code> object. So the object's identity works as a version number for the drag. Two selector calls that receive the <em>same</em> state object are on the same move, and the projection cannot differ between them. A call that receives a <em>new</em> object is on a new move, and every older projection is stale. That turns "once per move" into something checkable: <strong>cache the projection under the state object itself.</strong> The first reader of a given state object computes and stores; every later reader of that same object finds the entry and returns it; the next move brings a new object, whose lookup misses, so it computes exactly once. (This is also what keeps two <code>&lt;DndProvider&gt;</code>s on one page from colliding in the shared cache: each provider has its own store, so their state objects are never the same object, and their entries never overwrite each other.)</p>
<p><strong>What a WeakMap is, and why a plain</strong> <code>Map</code> <strong>would leak.</strong> The cache lives at module level for the life of the app, and its keys are state objects the store replaces at a rate of one per move — a few seconds of dragging at pointer frequency makes hundreds. In a plain <code>Map</code>, an entry keeps its key alive: long after the store has replaced a state object and nothing else in the program references it, the <code>Map</code> still would, holding the dead object and its projection until someone writes eviction code and picks a moment to run it. Nothing here ever would — the cache would only grow.</p>
<p>A <code>WeakMap</code> is a key→value map with one different rule: <strong>it does not keep its keys alive.</strong> When the only remaining reference to a key object is the WeakMap itself, the garbage collector is allowed to collect that object — and the entry, key and stored value together, disappears from the map with it. Applied here: the moment the store replaces <code>state</code>, the old state object's last reference is the cache, the collector reclaims it, and its projection goes too. Entries expire exactly when their move stops existing, with no eviction code anywhere. That is the entire reason this is a WeakMap and not a Map. The restriction that comes with it — a WeakMap cannot be iterated or counted, only asked "what is stored under this exact object?" — costs nothing here, because that lookup is the only operation this cache performs.</p>
<p><strong>Why two of them, nested.</strong> One WeakMap keyed by state would be enough if each move had exactly one projection. It does not, because one provider can hold several lists: two <code>&lt;SortableList&gt;</code>s under the same <code>&lt;DndProvider&gt;</code> share one store, so on any given move they read the <em>same</em> state object — while each needs its <em>own</em> projection, computed from its own rows. Keyed by state alone, whichever list computed first would win, and the second list's lookup would return the first list's projection.</p>
<p>So under each state object the cache holds another map, one entry per list, keyed by the thing that identifies a list: the <code>itemIds</code> array the consumer passed in (the tree projection keys by its <code>items</code> array the same way) — each list has its own array, and its reference is stable across a move. The two keys split the two questions:</p>
<pre><code class="language-plaintext">outer key: the state object       → which move is this?
inner key: the itemIds array      → which list is asking?
</code></pre>
<p>The inner map is a WeakMap for the same reason as the outer one: unmount a list and its array becomes unreachable, so its entries are collected instead of accumulating. And the inner key covers one more case — when the rows themselves change mid-drag (a tree auto-expanding under the cursor mounts new rows), the consumer passes a new array, so the changed list gets a fresh entry instead of a stale projection even while the state object is momentarily the same.</p>
<p>The read is a two-level lookup with one real computation on a miss:</p>
<pre><code class="language-ts">let byList = projectionCache.get(state)          // this move's bucket
if (!byList) { byList = new WeakMap(); projectionCache.set(state, byList) }

const cached = byList.get(args.itemIds)          // this list's entry
if (cached &amp;&amp; cached.direction === args.direction) return cached.projection  // O(1) hit

const projection = computeProjection(state, args)   // the one real computation
byList.set(args.itemIds, { direction: args.direction, projection })
return projection
</code></pre>
<p>Now — and this is the part I got wrong at first, so it is worth stating carefully — <strong>the payoff is different on the two paths, and they should not be confused.</strong></p>
<p>On the <strong>sortable list</strong>, each of the 24 rows has its own <code>useSortable</code>, and each calls <code>projectList(state, …)</code>. On one move, the <em>first</em> row to run computes the projection; the other 23 get an O(1) WeakMap hit off the same <code>state</code>. One computation, twenty-three cheap reads.</p>
<p>On the <strong>tree</strong>, <code>useTreeDrop</code> subscribes <strong>once, in the parent</strong> — tree rows are measure-only and never subscribe to a projection slice. So there is only one projection reader. But React reads a snapshot several times per move — the notify-time compare, the render read afterward (with a <em>fresh</em> selector closure each render), plus React's own tearing and DEV re-reads — and without the map, each would re-walk <code>flattenTree</code> + <code>projectTreeDrop</code>. The map collapses them all to one compute per move.</p>
<p>Different mechanism, same rule:</p>
<blockquote>
<p>The projection is computed <strong>once per store-state version</strong> — no matter how many rows read it, or how many times React asks.</p>
</blockquote>
<p>Get that wrong — put the computation <em>inside</em> the selector — and the render counts still look perfect while the move path quietly goes O(N²) and the frame is gone. That is why this is the architecture's binding constraint, not a tuning detail.</p>
<hr />
<h2>4 · The limits</h2>
<p>Honesty about the edges.</p>
<p><strong>v0.1 ships one collision strategy</strong> (<code>closestCenter</code>), no cross-list moves, no cross-tree moves, and no drop animation. The tree is <strong>indicator-only</strong> — rows do not reflow live during a tree drag; one indicator line moves. Deliberate scope cuts, not oversights.</p>
<p><strong>It targets React 19+.</strong> That is the only version tested, so it is the only version claimed.</p>
<p><strong>Very large trees have a ceiling.</strong> The selector floor scales with row count — every subscriber's selector runs on every notification whether or not it renders — so past the low tens of thousands of rows the honest answer is virtualization, which is genuinely harder because rects exist only for mounted rows.</p>
<p>And the plain trade: if your list is flat and small, a library that re-renders every row on every move feels identical to this one. This architecture makes a measurable difference only when the list is long, or the structure is a tree, or both.</p>
<hr />
<p>The store holds the truth, outside React.</p>
<p>The pure tree math turns a gesture into a position.</p>
<p>The map computes that position once, so React barely notices the drag at all.</p>
]]></content:encoded></item><item><title><![CDATA[Big O notation]]></title><description><![CDATA[I just figured out a simple way to understand BigO notation, aka the algorithm complexity numbers.
Let's say you have your myFn function and inside it you have some logic (an algorithm) that takes som]]></description><link>https://featuringcode.com/big-o-notation</link><guid isPermaLink="true">https://featuringcode.com/big-o-notation</guid><category><![CDATA[algorithms]]></category><category><![CDATA[#big o notation]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Fri, 14 Aug 2026 18:13:34 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/a0ca82ef-3653-48cf-82ee-d12dbe1af61d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I just figured out a simple way to understand BigO notation, aka the algorithm complexity numbers.</p>
<p>Let's say you have your <code>myFn</code> function and inside it you have some logic (an algorithm) that takes some inputs (<strong>n</strong> elements).</p>
<blockquote>
<p>One concept you need to understand is that a machine can roughly do <em><strong>~10⁹ operations per second</strong></em>.</p>
</blockquote>
<p>An operation can be a <em>comparison</em>, an <em>addition</em>, an <em>arr[i]</em>, an <em>i++</em>. So if you want to check for yourself, paste this code in your browser console:</p>
<pre><code class="language-javascript">const myFn = (n) =&gt; {
  let i = 0
  while (i &lt; n) {
    i++
  }
}

const myCalls = 1_000_000_000 // billion, with a "B" or 10⁹

const s = performance.now();
myFn(myCalls);
console.log(`run ${(performance.now() - s).toFixed(0)} ms`);
run 956 ms // roughly 1 second
</code></pre>
<p>As you see, it's a <code>run 956 ms</code>. Careful though — this number is not stable: run it a few times, or on a different machine, and you'll get anywhere from ~250ms to ~1.1s for the exact same code. It depends on your machine's state (other tabs, memory pressure) and how aggressively the engine optimises the loop.<br />Also note, this is just an example to give you a feel of what an "operation", which in this case is i++.</p>
<p>And now, the fun part. The above loop is <code>O(n)</code> — it does one operation per element, so <code>n</code> operations in total. And since the machine does ~10⁹ operations per second, the most it can get through in one second is about <em><strong>10⁹ elements.</strong></em></p>
<p>So if you do <code>myFn(5 * myCalls)</code> — that's 5× the operations — it takes roughly 5× as long. That's exactly what linear (<code>O(n)</code>) means. Don't count on an exact number though: the wall-clock swings a lot with your machine and environment (on native Node it's ~2s; a browser-based playground running Node in WebAssembly can be 5s+). The shape is the point — 5× the work, ~5× the time.</p>
<p>And here is why algorithm complexity matters. It basically answers you this question:</p>
<blockquote>
<p><strong>For a given complexity, how many items can this algorithm process in one second?</strong></p>
</blockquote>
<p>or if you want it more academic sounding</p>
<blockquote>
<p><strong>Given a rough computation budget, how large can</strong> <code>n</code> <strong>get for an algorithm of this complexity?</strong></p>
</blockquote>
<p>Bigger complexity means fewer items. Which also means, depending on how big your <code>n</code> is, it can take from a few ms to hours to process.</p>
<p>Here is a table which tells you how many n elements can your algorithm process in 1 second with ~10⁹ simple operations as our one-second budget.</p>
<table>
<thead>
<tr>
<th>Complexity</th>
<th>Name</th>
<th>Max <code>n</code> in ~1s (ballpark)</th>
</tr>
</thead>
<tbody><tr>
<td><code>O(1)</code></td>
<td>constant</td>
<td>unbounded</td>
</tr>
<tr>
<td><code>O(log n)</code></td>
<td>logarithmic</td>
<td>astronomically large</td>
</tr>
<tr>
<td><code>O(√n)</code></td>
<td>root</td>
<td>~10¹⁸</td>
</tr>
<tr>
<td><code>O(n)</code></td>
<td>linear</td>
<td>~10⁹</td>
</tr>
<tr>
<td><code>O(n log n)</code></td>
<td>linearithmic</td>
<td>~10⁷</td>
</tr>
<tr>
<td><code>O(n²)</code></td>
<td>quadratic</td>
<td>~10⁴–10⁵</td>
</tr>
<tr>
<td><code>O(n³)</code></td>
<td>cubic</td>
<td>~10³</td>
</tr>
<tr>
<td><code>O(2ⁿ)</code></td>
<td>exponential</td>
<td>~30</td>
</tr>
<tr>
<td><code>O(n!)</code></td>
<td>factorial</td>
<td>~12</td>
</tr>
</tbody></table>
<p>Note that these numbers are an approximation, not fixed numbers, but it's a starting point that can help you better visualize what these complexities are all about.</p>
<p>Let me know if it clicked for you as it clicked for me.</p>
]]></content:encoded></item><item><title><![CDATA[Block-addressed editing: a 10x output-token cost reduction for document LLM agents]]></title><description><![CDATA[This works perfectly when your LLM agent reads a document and updates it — the user asks "in the third paragraph, change 'retried three times' to 'retried five times'", and the agent edits the page.
H]]></description><link>https://featuringcode.com/block-addressed-editing-a-10x-output-token-cost-reduction-for-document-llm-agents</link><guid isPermaLink="true">https://featuringcode.com/block-addressed-editing-a-10x-output-token-cost-reduction-for-document-llm-agents</guid><category><![CDATA[llm]]></category><category><![CDATA[tokens]]></category><category><![CDATA[cost-optimisation]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Wed, 22 Jul 2026 22:43:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/93415957-360c-4356-b5d0-dec74d4dd001.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This works perfectly when your LLM agent reads a document and updates it — the user asks <em>"in the third paragraph, change 'retried three times' to 'retried five times'"</em>, and the agent edits the page.</p>
<p>Here is the fast and costly implementation — the one almost everyone ships first:</p>
<pre><code class="language-plaintext">the model read the whole document
the model re-typed the whole document
we saved the whole document
</code></pre>
<p>Re-typed, literally: the edit tool's output is the complete new version of the page.</p>
<p>The document I used in the measurements below — is ~7.4 KB of markdown, which is roughly 1,550 tokens. So changing one sentence means the model <em>generates</em> ~1,560 tokens: the one sentence you asked for, plus every paragraph you didn't.</p>
<p>One changed sentence. ~1,560 generated tokens. And if you add new content, those output tokens start growing fast.</p>
<p>To see why that's the worst possible shape, look at how LLM pricing is split. Every call has two lanes:</p>
<pre><code class="language-plaintext">input  — the tokens you send: the conversation so far, the document
output — the tokens the model generates
</code></pre>
<p>The lanes are not priced alike. On a typical frontier model today, input runs on the order of $1–3 per million tokens while output runs $10–15 per million — roughly <strong>8× more expensive</strong>. And the real gap is wider than the list price, because the two lanes cache differently: providers discount <em>repeated input</em> heavily — a prompt prefix the model has recently seen costs around a tenth of the normal input price — so re-sending the same document every turn is surprisingly cheap. Output gets no such discount. There is no cache for generation; every output token is full price, every time.</p>
<p>So: input is the cheap, cached lane. Output is the expensive, uncached one. And the implementation above answers a one-sentence request by pushing the entire document through the expensive lane.</p>
<p><strong>Where the idea came from.</strong> Not from the LLM world at all — from the internals of Slate, the framework many block-based rich-text editors are built on. When you type into a <a href="https://github.com/ianstormtaylor/slate">Slate</a> document, the page does not re-render. Slate keeps WeakMaps tracking which paths of the document actually changed — the <em>dirty paths</em> — and only the components for those blocks re-render. The clean blocks aren't re-rendered cheaply. They aren't re-rendered <em>at all</em>.</p>
<p>Staring at that, the question asked itself: my UI refuses to re-render blocks that didn't change — so why is my agent re-<em>emitting</em> them? Output tokens are the agent's render budget, and it was re-rendering the entire page on every keystroke.</p>
<p>That analogy is what led to the ~10× output cut below. (One honest note: the mechanism I ended up shipping looks less like React and more like git — content fingerprints compared at write time, rather than live dirty-tracking — but the question that started everything came straight from the renderer.)</p>
<p>This post is the method, with A/B numbers from a production agent: give every block of the document a <strong>stable id</strong>, let the agent address blocks instead of re-emitting documents, and verify every edit against a <strong>content fingerprint</strong> taken when the agent read the page.</p>
<p>Across a real five-edit session, output dropped from <strong>8,128 tokens to 775</strong> — about 10× — and edits came back <strong>~3× faster</strong>. A second, independent technique (history compaction) cut multi-turn input by <strong>~60%</strong> on top.</p>
<p>None of the individual ingredients here is my invention — I'll point at the neighbours later. What I can give you is the full composition for structured documents, traced end to end, with measurements.</p>
<hr />
<h2>First, how a writing agent actually works</h2>
<p>If you already build agents, skip ahead. But the two facts in this section are load-bearing for everything after.</p>
<p>An agent is a model in a loop. You send a prompt; the model either answers or asks to call a <strong>tool</strong> — a function you exposed, like <code>read_document</code> or <code>search</code>. Your server runs the tool, appends the result to the conversation, and calls the model again. Repeat until the model answers without asking for a tool. This loop (reason → act → observe) is usually called ReAct.</p>
<pre><code class="language-plaintext">user prompt
↓
model → "call edit tool with these args"
↓
server runs the tool, appends the result
↓
model → "done, here's my summary"
</code></pre>
<p>Fact one: <strong>the model has no hands.</strong> It never touches your database. It only <em>describes</em> operations; your server performs them. Like SQL — the client sends <code>UPDATE … WHERE id = …</code>, the database executes it.</p>
<p>Fact two: <strong>the model has no memory.</strong> Every turn, you re-send the entire conversation so far — the transcript. A follow-up tomorrow is a new HTTP request; your server loads the stored history (for example a JSON on a per-session database row), replays it to the model, and appends the new message. Anything you want the model to "remember" is something you are paying to re-send, every single turn.</p>
<p>Those two facts define the two costs this post attacks:</p>
<ul>
<li><p><strong>Output</strong> — everything the model generates, including tool arguments. Expensive, never cached.</p>
</li>
<li><p><strong>Input</strong> — the replayed transcript. Cheaper, cache-discounted, but re-sent every turn, so it compounds.</p>
</li>
</ul>
<hr />
<h2>The prerequisite: a stable id on every block</h2>
<p>Here is the entire architectural bet, and it's one sentence:</p>
<p><strong>A block of text (data) must have an identity that survives its content changing.</strong></p>
<p>Most block-based editors already assign every block a short random id at creation time — for deep links, for drag-and-drop, for anchoring comments. If yours does, the prerequisite is free. That id is the <em>handle</em> the agent will use to say which block it means.</p>
<p>Why must it be an id? Why not "the third paragraph", or a hash of the block's content? Because those are the three possible addressing schemes, and two of them die on contact with a live document:</p>
<ul>
<li><p><strong>Position</strong> ("paragraph 3", "line 42") dies when anyone inserts or deletes above the target. Every address below the change shifts.</p>
</li>
<li><p><strong>Content</strong> (a hash of the block's text) dies when anyone edits the block — the address <em>was</em> the content. Worse, it can't distinguish "this block moved" from "this block was deleted and a similar one created".</p>
</li>
<li><p><strong>Identity</strong> (an id stored <em>on</em> the block) survives both. The block can move, the text can change; the id rides along.</p>
</li>
</ul>
<p>The system needs to express one specific sentence: <em>"the same block, changed."</em> Only identity-addressing can say it — position and content addressing each collapse "same block" into the thing that changed. Hold that sentence; the fingerprints in the next section exist to finish it.</p>
<p>(If some blocks lack ids — legacy content — you can fall back to content+position handles. It works, degraded: addresses die on any edit, so every conflict looks like "block not found" instead of "block changed", and the follow-up optimization below gives up more often. Our system assigns real ids to everything the agent writes, so documents heal toward fully-addressed as they're edited.)</p>
<hr />
<h2>Markdown is the language of the agent</h2>
<p>The document lives in the database as a JSON tree. The model never sees that.</p>
<p>Everything the model reads, and everything it writes, is <strong>markdown</strong>. On read, the server renders each block to markdown; on write, the model sends markdown back and the server parses just that fragment into blocks.</p>
<p>Two reasons, one obvious and one that took me longer:</p>
<p>The obvious one: models are dramatically better at markdown than at your internal JSON. They've read a lifetime of it. Emitting a paragraph of markdown is cheap and reliable; emitting your nested block schema, with every property right, is neither — and it spends tokens on syntax instead of content.</p>
<p>The less obvious one is about the fingerprints. The system needs to answer <em>"has this block changed since the agent read it?"</em> — which basically means:</p>
<pre><code class="language-plaintext">"Would the model see different text at response time than it saw at read time?"
</code></pre>
<p>So we don't fingerprint the stored JSON. We fingerprint <strong>the rendered markdown</strong> — a SHA-1 of exactly the text the model was shown, produced by the same renderer at read time and at check time. That buys precision in both directions:</p>
<ul>
<li><p>Internal properties can churn (metadata, layout props, id reassignments) without triggering a false conflict — if the rendered text is identical, the block is not stale <em>for the model's purposes</em>.</p>
</li>
<li><p>Any change the model <em>would</em> see, however small, flips the hash completely.</p>
</li>
</ul>
<p><em><strong>The fingerprint is a statement about the model's view, not about storage bytes. Hash the view, because the view is what the model's decisions were based on.</strong></em></p>
<p>One more property matters: the fingerprints are cheap. ~50 bytes per block to store, microseconds to compute. A 200-block document snapshots to ~10 KB regardless of how much text the blocks hold.</p>
<hr />
<h2>The read: addresses to the model, fingerprints to the server</h2>
<p>Take a five-block document — a webhooks guide for example:</p>
<pre><code class="language-plaintext">## Webhooks
Deliveries are sent whenever a document is published.
Every request carries a signature header.
Failed deliveries are retried three times.
You can replay any delivery from the dashboard.
</code></pre>
<p>When the agent reads it, one walk over the blocks produces <strong>two artifacts with two destinations</strong>:</p>
<pre><code class="language-plaintext">stored blocks (JSON tree, database)
↓  render each block to markdown, hash each rendering
┌──────────────────────────────┬──────────────────────────────┐
│ anchored markdown            │ snapshot                     │
│ → goes TO the model          │ → stays on the SERVER        │
└──────────────────────────────┴──────────────────────────────┘
</code></pre>
<p>The model receives the full document — every block, complete — with one <strong>anchor line</strong> naming each block's id:</p>
<pre><code class="language-plaintext">&lt;!-- block:h1K2p9 --&gt;
## Webhooks

&lt;!-- block:aB3xK9 --&gt;
Deliveries are sent whenever a document is published.

&lt;!-- block:pQ7mN2 --&gt;
Every request carries a signature header.

&lt;!-- block:zT5rW8 --&gt;
Failed deliveries are retried three times.

&lt;!-- block:qF4dR7 --&gt;
You can replay any delivery from the dashboard.
</code></pre>
<p>The server keeps the <strong>snapshot</strong>: for each block, the handle and the hash of its rendered markdown (without the anchor lines, just the actual text from the document).</p>
<pre><code class="language-json">{ "docId": "doc_web1",
  "blocks": [
    { "handle": "h1K2p9", "hash": "6a5362c0…" },
    { "handle": "aB3xK9", "hash": "80c7da16…" },
    { "handle": "pQ7mN2", "hash": "0db39487…" },
    { "handle": "zT5rW8", "hash": "01e5960c…" },
    { "handle": "qF4dR7", "hash": "27788d86…" }
  ] }
</code></pre>
<p>The snapshot is <em>the server's record of what the model saw</em>. During a turn it's an in-memory map; between turns it's persisted as a JSON column on the same database row that stores the session's conversation history. The model never sees a hash. It doesn't know hashes exist.</p>
<pre><code class="language-plaintext">anchors      → the model   ("which block do you mean?")
fingerprints → the server  ("is that block still what you read?")
</code></pre>
<hr />
<h2>The edit: change the third paragraph from X to Y</h2>
<p>Now the running example. You tell the agent:</p>
<pre><code class="language-plaintext">In the third paragraph, change "retried three times" to "retried five times".
</code></pre>
<p><strong>What the model does.</strong> It finds the third paragraph <em>by reading</em> — the full document is in its input, so "third paragraph" is a comprehension task, not a lookup API. That's block <code>zT5rW8</code>. It emits one tool call:</p>
<pre><code class="language-json">{ "tool": "edit_blocks", "docId": "doc_web1",
  "operations": [
    { "op": "replace", "blockId": "zT5rW8",
      "markdown": "Failed deliveries are retried five times." }
  ] }
</code></pre>
<p><strong>That is the model's entire output for this edit!!!</strong> An address, and the new text for one block. In our logs: <strong>80–170 output tokens</strong> per edit, against ~1,550 for the full rewrite. The other four blocks were read, understood, and never retyped.</p>
<p>(The tool speaks five operations: <code>replace</code>, <code>insert_before</code>, <code>insert_after</code>, <code>delete</code>, <code>append</code>. "Add a note under the heading" is an <code>insert_after</code>; "remove that paragraph" is a <code>delete</code>. Same machine throughout.)</p>
<p><strong>What the server does.</strong> Before touching anything, it asks one question — <em>is the block the model wants to change still the same as when the model read it?</em></p>
<p>The server does <strong>not</strong> hash the markdown the model just sent. Of course that hash would differ — new content hashing differently is the <em>point</em> of new content.</p>
<p>Instead, three versions of the target block are on the table:</p>
<pre><code class="language-plaintext">the PAST     — the snapshot hash: what the model READ
the PRESENT  — the block as stored RIGHT NOW, re-rendered, re-hashed
the FUTURE   — the op's markdown: what the model WANTS
</code></pre>
<p>The check compares <strong>past against present</strong>. The future is never hashed — it's the payload, not the evidence. In words:</p>
<pre><code class="language-plaintext">"Since the model read this block, has anyone changed it?
 No  → the edit was decided while looking at current text. Apply it.
 Yes → the edit was decided on a stale picture. Refuse — and show the model the present."
</code></pre>
<p>For our edit: the present renders to <code>Failed deliveries are retried three times.</code>, which hashes to <code>01e5960c…</code> — equal to the snapshot. Not stale. Proceed.</p>
<p><strong>The apply is array surgery, not document reassembly.</strong> The server parses the model's one line of markdown into one new block (assigning it a fresh id), finds the target's position, and splices:</p>
<pre><code class="language-plaintext">[ h1K2p9, aB3xK9, pQ7mN2, zT5rW8, qF4dR7 ]
                          ↑ replace
[ h1K2p9, aB3xK9, pQ7mN2, NEW,    qF4dR7 ]
</code></pre>
<p>Look at the four other blocks. They are the <strong>same stored objects</strong>. Not re-rendered, not re-parsed, not re-created — spliced <em>around</em>. This is the property no text-file edit format can give you, because a text file has no richer layer to protect: in a structured store, an untouched block is not a faithful reproduction of itself. It's the same bytes. It cannot drift, because it never moved.</p>
<p>Then one save, and the server refreshes its snapshot so the model's own edit counts as "seen" — otherwise the agent's next operation would collide with its own work.</p>
<p><strong>And if someone else edited that paragraph first?</strong> Say a teammate changed it to <code>Failed deliveries are retried with exponential backoff.</code> while the model was thinking. The present now hashes to <code>d80a9820…</code>; the snapshot still says <code>01e5960c…</code>. Mismatch → the whole call is rejected atomically (every operation validated before any is applied — a failed call means a byte-identical document), and the error <em>is</em> a targeted re-read:</p>
<pre><code class="language-plaintext">Error: block "zT5rW8" was modified after you read it.
Current content of this block:
Failed deliveries are retried with exponential backoff.
Re-issue the operation against this content.
</code></pre>
<p>~100 tokens, and the model retries against reality instead of its recollection. The same style of self-correcting error covers a hallucinated block id (the error returns the real outline: every current handle plus the first 60 characters of its block). The failure mode is always <em>"no edit — try again, informed"</em>, never <em>"wrong edit"</em>. This is per-block optimistic concurrency, the same idea as <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag">HTTP's ETag</a>/<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-Match">If-Match</a>, applied per paragraph: a teammate editing block 12 never blocks the agent's edit of block 3.</p>
<hr />
<h2>The second turn: what the agent receives tomorrow</h2>
<p>Next day, same session, you ask: <em>"actually, make that paragraph mention the backoff delay too."</em></p>
<p>Look at what the model's input actually is tomorrow:</p>
<pre><code class="language-plaintext">[replayed history]
  turn 1: the FULL anchored document        ← still here, re-sent verbatim
  turn 1: the model's own edit_blocks call  ← its record of what it changed
  turn 1: "Edited: replaced block zT5rW8"
[fresh]
  [Document unchanged since your last read]  ← the delta line your new message
</code></pre>
<p>The model re-reads yesterday's full document <em>every turn</em> — that's what replaying history means. The one-line marker isn't the document. It's a <strong>certificate</strong>: "that copy of the document sitting earlier in your transcript, plus your own edits — still accurate. Trust it."</p>
<p>That reframing answers the cost question too, which is the part that makes the design clever rather than just correct. The full document <em>is</em> being paid for every turn — but in the <strong>input</strong> lane, as part of a byte-stable replayed prefix, which is exactly the lane that's cheap and cache-discounted.</p>
<p>This is a fresh HTTP request. The server replays the stored conversation — and here the doc snapshot earns its keep a second time. Instead of stapling a fresh full copy of the document onto the transcript (what we used to do, every turn), the server re-renders the current blocks, diffs their hashes against the snapshot, and injects one of three things:</p>
<pre><code class="language-plaintext">nothing changed        → one line:
                         [Document unchanged since your last read]

a few blocks changed   → only those blocks, with their anchors:
                         --- Changed blocks ---
                         &lt;!-- block:zT5rW8 --&gt;
                         Failed deliveries are retried five times.
                         --- End of changed blocks ---

a lot changed (&gt;30%)   → give up, re-inject the whole document
</code></pre>
<p>The model's picture of the document is now three layers, all cheap: the original full injection (still in the replayed history), its own edit operations (also in history — the record of what it changed), and this delta. Full context, delta price. In our runs a typical delta injection was ~220 tokens where a full re-read was thousands.</p>
<p>If you know <a href="https://en.wikipedia.org/wiki/Rsync">rsync</a>, this is rsync — the snapshot plays the role of the receiver's checksum list, except the "receiver" being synchronized is the model's context window.</p>
<hr />
<h2>The other lever: compact the replayed history</h2>
<p>Block editing cuts what the model <em>writes</em>. The transcript it <em>reads</em> every turn has its own disease, and a different cure.</p>
<p>By turn five of a session, the replayed history was carrying: the turn-1 document injection, a fresh full injection stapled to every user message, every full-document rewrite the model ever emitted as tool arguments, and every raw search-result dump. The same document, over and over, in slightly different wrappers — we measured <strong>71,812 input tokens</strong> re-sent at the start of a single turn.</p>
<p>Compaction is a pure function that runs when history is replayed. No summariser model, no AI call. It walks the stored messages and swaps known bulky payloads for one-line placeholders:</p>
<table>
<thead>
<tr>
<th>In history</th>
<th>Becomes</th>
</tr>
</thead>
<tbody><tr>
<td>Old document injections</td>
<td><code>(content elided — a fresh copy is in the latest message)</code></td>
</tr>
<tr>
<td>Old full-rewrite tool arguments</td>
<td><code>[document content elided — see the current document or read it again]</code></td>
</tr>
<tr>
<td>Old read-tool results</td>
<td><code>[document content elided — call read_document again if needed]</code></td>
</tr>
<tr>
<td>Old search-result dumps</td>
<td><code>[search results elided]</code></td>
</tr>
</tbody></table>
<p>Read the placeholders again — they aren't apologies, they're <strong>directions</strong>. Each one tells the model where the authoritative version now lives: the fresh injection, or a tool it can call again. That's the rule the whole elision list was built from: <em>remove bytes only when their authoritative version still reaches the model somewhere else.</em> The user's words, the model's own replies and decisions, tool names, error messages — never touched.</p>
<hr />
<h2>The numbers</h2>
<p>Everything below is from A/B runs of a production agent (GPT-5.1) against the same ~7.4 KB document with the same five small, surgical edit prompts — one variable toggled per experiment, token counts read straight from per-iteration logs.</p>
<p><strong>Output — the headline.</strong> The edit call itself, per turn:</p>
<table>
<thead>
<tr>
<th>Edit</th>
<th>block editing</th>
<th>whole-doc rewrite</th>
<th>ratio</th>
</tr>
</thead>
<tbody><tr>
<td>#1</td>
<td>162</td>
<td>1,644</td>
<td>10.1×</td>
</tr>
<tr>
<td>#2</td>
<td>168</td>
<td>1,529</td>
<td>9.1×</td>
</tr>
<tr>
<td>#3</td>
<td>83</td>
<td>1,540</td>
<td>18.6×</td>
</tr>
<tr>
<td>#4</td>
<td>145</td>
<td>1,544</td>
<td>10.6×</td>
</tr>
<tr>
<td>#5</td>
<td>80</td>
<td>1,556</td>
<td>19.5×</td>
</tr>
</tbody></table>
<p>Session totals: <strong>775 vs 8,128 output tokens — ~10× less</strong>. And the saving is <em>flat</em>: the rewrite pays full document price for a one-word change on turn five exactly as on turn one. There is no session length at which it catches up.</p>
<p><strong>Speed.</strong> Median edit latency <strong>5.3 s vs 16.9 s (~3.2×)</strong>; averages 6.8 s vs 16.7 s. Generating ~1,550 tokens takes real wall-clock time; generating ~130 doesn't. This is the difference users feel on every single edit.</p>
<p><strong>Input, as a side effect.</strong> Because block editing never deposits a full-document rewrite into the history, the replayed transcript grows ~<strong>294 tokens/turn instead of ~3,176</strong> — 11× slower. Over five edits: 78,034 vs 180,032 input tokens (−57%) — without compaction even enabled.</p>
<p><strong>Compaction, measured separately</strong> (block editing off in both runs, so the only variable is compaction):</p>
<table>
<thead>
<tr>
<th>Follow-up</th>
<th>compacted input</th>
<th>uncompacted input</th>
</tr>
</thead>
<tbody><tr>
<td>#1</td>
<td>14,790</td>
<td>15,063</td>
</tr>
<tr>
<td>#3</td>
<td>17,488</td>
<td>43,716</td>
</tr>
<tr>
<td>#5</td>
<td>20,880</td>
<td>71,812</td>
</tr>
</tbody></table>
<p>Turn one is a tie — nothing to elide yet. By turn five, <strong>−71%</strong>; across the session, <strong>−60%</strong>, and the gap widens every turn. With compaction on, the transcript grows roughly as fast as the document itself; without it, by about a whole extra document copy per turn.</p>
<p><strong>Correctness — the result that surprised me.</strong> Both modes applied <strong>5/5 edits correctly</strong>, including two deliberate traps: a decoy (the target phrase appeared twice; only the asked-for instance was to change) and a two-mention update ("change every place it says 24 hours"). So I won't tell you block editing is <em>more accurate</em> — on well-specified edits it measurably isn't. The honest claim is different: it's equally correct while re-generating ~60× less text per edit, which means a far smaller surface for silent drift, and a ~3× shorter window in which a dropped connection can kill a half-finished rewrite. Same outcome, smaller blast radius.</p>
<p><strong>How the two levers stack.</strong> They overlap on input (both attack the same history bloat from different ends), so the savings don't add — but they own different lanes, and the lanes are clean:</p>
<pre><code class="language-plaintext">block editing owns OUTPUT  (~10× less, ~3× faster — compaction can't touch output)
compaction    owns INPUT   (~60% less replayed history, compounding with session length)
</code></pre>
<p>One honest asterisk on compaction: rewriting the transcript prefix disturbs prompt-cache locality, so its effect on <em>provider-side cost</em> depends on your traffic pattern (with warm caches it's roughly neutral; with cold caches it wins outright). Its effect on raw token counts — which is also what usage quotas typically meter — is the clean ~60%.</p>
<hr />
<h2>Prior art, honestly</h2>
<p>I arrived at this independently, and then discovered — as you should assume about any good idea under economic pressure — that the field was converging on the pieces. The pricing asymmetry (expensive uncached output, cheap cached input) pushes everyone toward the same shape:</p>
<ul>
<li><p><a href="https://aider.chat/docs/more/edit-formats.html"><strong>Aider's search/replace edit formats</strong></a> attack the same retyping problem for code: emit only the diff, never the who le file (their <a href="https://aider.chat/docs/unified-diffs.html">unified-diffs write-up</a> is the classic argument against whole-file rewrites).</p>
</li>
<li><p><a href="https://antirez.com/news/166"><strong>antirez sketched line edits</strong></a> carrying a small content checksum, checked before apply — verify-before-write at line granularity, pos itional addressing.</p>
</li>
<li><p><a href="https://betterstack.com/community/guides/ai/oh-my-pi-ai-coding-agent/"><strong>OMP's "hashline" edits</strong></a> give every <em>line</em> a content-hash the model references instead of r etyping — content addressing, with the hashes travelling through the model's context.</p>
</li>
<li><p><a href="https://claude.com/blog/context-management"><strong>Anthropic's context editing</strong></a> clears stale tool results from the transcript and leaves placeholders — the same family as our compaction.</p>
</li>
<li><p><a href="https://developers.notion.com/reference/update-a-block"><strong>Notion's API exposes block-id-targeted updates</strong></a> — stable ids on blocks, commercially at scale.</p>
</li>
</ul>
<p>What I haven't seen assembled anywhere public is the composition this post describes, and each piece of the composition does specific work: <strong>identity</strong> addressing rather than position or content (addresses that survive both movement and editing); fingerprints of the <strong>rendered view</strong> rather than the storage bytes (staleness defined as "would the model see different text", eliminating false conflicts); fingerprints held <strong>server-side</strong> rather than routed through the model (no per-line token tax, nothing for the model to mangle); the persisted snapshot doing <strong>double duty</strong> — guarding writes <em>and</em> powering the delta re-injection that keeps follow-up turns cheap; and, available only because the document store is ours, untouched blocks that are <strong>never serialized at all</strong>. The deepest ancestry is older than all of us: per-block ETag/If-Match, git's content hashing, rsync's checksum lists — pointed at a context window instead of a cache or a network link.</p>
<p>If you're building an agent over any block-structured store — a CMS, a notes product, a site builder, an email composer — that composition transfers whole.</p>
<hr />
<h2>The honest limits</h2>
<p><strong>Input still scales with document size.</strong> The model reads everything, every session. Deliberate: input is the cheap, cacheable lane, and full context is what keeps edits grounded. This method saves where it's expensive and spends where it's safe.</p>
<p><strong>One giant block is still one giant block.</strong> <code>replace</code> re-emits the whole target block, so nothing is saved <em>inside</em> a 20k-token table. If your users build monster blocks, you'll eventually want sub-block addressing.</p>
<p><strong>Cross-block renames are the wrong job for it.</strong> An 18-occurrence rename cost 855 output tokens in our runs, because the model re-emitted every affected block. A plain server-side find-and-replace tool does it for ~40.</p>
<p><strong>The concurrency is optimistic and per-block, not a global transaction.</strong> It catches the overwhelmingly common conflicts — and its failure mode is refusal, not corruption — but if you need multi-writer guarantees across blocks, that's CRDT territory, a different (and far heavier) machine.</p>
<p><strong>Blocks without ids degrade the experience, not the safety.</strong> Content+position fallback handles keep edits sound but turn "block changed" conflicts into blunt "block not found" errors and make the delta optimization give up more often.</p>
<hr />
<h2>If you want to build this</h2>
<p>The checklist is short:</p>
<ol>
<li><p><strong>You own a structured document store</strong> — a tree of typed blocks, not opaque text files.</p>
</li>
<li><p><strong>Every block carries a stable id.</strong> If your editor already assigns ids for deep links or drag-and-drop, this is free. This is the prerequisite everything else stands on.</p>
</li>
<li><p><strong>One deterministic per-block markdown renderer</strong>, used at read time <em>and</em> check time — the hashes are only comparable because both come from the same function.</p>
</li>
<li><p><strong>Somewhere to persist the per-session snapshot</strong> — a JSON column next to your conversation history is plenty; ~50 bytes per block.</p>
</li>
<li><p><strong>Errors that teach.</strong> Every rejection should hand the model the current truth it was missing — the block's present content, the real outline. The error channel is your sync channel.</p>
</li>
</ol>
<p>Then the flow is the one you've just read:</p>
<pre><code class="language-plaintext">The read hands the model addresses and keeps the fingerprints.
The edit names an address, and the fingerprints judge it — past against present — before anything moves.
The follow-up diffs the fingerprints, so the model's picture stays current at delta price.
</code></pre>
<p>The model reads everything, addresses one block, and retypes nothing else. That single change moved our bill's most expensive line item by 10× — and made the agent faster and <em>less</em> destructive at the same time. The rare alignment of cheaper, faster, and safer is usually a sign the previous design was paying for something nobody needed. Re-typed text nobody asked for was exactly that.</p>
]]></content:encoded></item><item><title><![CDATA[The only TypeScript you need to appear knowledgeable]]></title><description><![CDATA[Most TypeScript "knowledge" is a handful of ideas applied over and over. This is that handful — explained through the logic, not the syntax.
One rule to keep in your head the whole way down:

TypeScri]]></description><link>https://featuringcode.com/the-only-typescript-you-need-to-appear-knowledgeable</link><guid isPermaLink="true">https://featuringcode.com/the-only-typescript-you-need-to-appear-knowledgeable</guid><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Tue, 14 Jul 2026 20:12:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/f0b4830b-7c46-4eab-8ab8-9a810a4469e2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most TypeScript "knowledge" is a handful of ideas applied over and over. This is that handful — explained through the logic, not the syntax.</p>
<p>One rule to keep in your head the whole way down:</p>
<blockquote>
<p><strong>TypeScript has two universes: the value space (runtime) and the type space (compile time).</strong> <code>const</code>, <code>let</code>, functions, objects live in the value space. <code>type</code>, <code>interface</code>, <code>keyof</code> live in the type space. <code>typeof</code> is the door from value space into type space. Almost everything below is a consequence of that.</p>
</blockquote>
<hr />
<h2>1. Interfaces (and the fact you can declare one twice)</h2>
<p>Let's say you're modeling a user, and you declare it twice <strong>in the same file</strong>:</p>
<pre><code class="language-ts">// user.ts
interface User {
  id: string;
  email: string;
}

interface User {
  plan: 'free' | 'pro';
}

// User is now { id: string; email: string; plan: 'free' | 'pro' }
</code></pre>
<p>Nobody overwrote anything. Two declarations of the same interface name <strong>merge</strong> their members. This is called <em>declaration merging</em>, and it is the one thing <code>interface</code> can do that <code>type</code> cannot.</p>
<h3>The rule that everyone gets wrong</h3>
<p>Merging happens <strong>within a declaration space</strong>, not within a project. And <strong>every file with a top-level</strong> <code>import</code> <strong>or</strong> <code>export</code> <strong>is its own module — its own declaration space.</strong></p>
<p>So this does <em>not</em> merge the interface:</p>
<pre><code class="language-ts">// user.ts
export interface User { id: string; email: string }

// plugin.ts
export interface User { plan: 'free' | 'pro' }
</code></pre>
<p>These are two <strong>unrelated types that happen to share a name</strong>. <code>import { User } from './user'</code> still gives you <code>{ id, email }</code>. And if <code>plugin.ts</code> tries to import the original alongside its own declaration, you don't get a merge — you get <code>Import declaration conflicts with local declaration of 'User'</code>.</p>
<p>A file only merges into another file's types in two situations.</p>
<p><strong>1. Both files are global.</strong> A file with <em>no</em> top-level import/export isn't a module — it's a script, and its declarations land in the global space:</p>
<pre><code class="language-ts">// globals.d.ts — no imports, no exports, deliberately
interface Window {
  __APP_STATE__: AppState; // ✅ merges into lib.dom's Window
}
</code></pre>
<pre><code class="language-ts">// app.ts
window.__APP_STATE__; // ✅ compiles
</code></pre>
<p>Now add one line to the bottom of <code>globals.d.ts</code>:</p>
<pre><code class="language-ts">// globals.d.ts - adds an export in the file
export {};
</code></pre>
<pre><code class="language-ts">// app.ts
window.__APP_STATE__;
// ❌ Property '__APP_STATE__' does not exist on type 'Window &amp; typeof globalThis'.
</code></pre>
<p>Nine characters with no semantic content, and the merge is gone. <code>globals.d.ts</code> is now a <em>module</em>, so its <code>Window</code> is a private local interface that happens to share a name with the real one.</p>
<p>Note <strong>where the error lands</strong>: in <code>app.ts</code> — the file you didn't touch. The file you actually broke reports nothing at all. This is why the bug is so expensive: you go hunting in the consumer, and the cause is a stray <code>export</code> (or an editor-inserted auto-import) in a declaration file you weren't even looking at.</p>
<p>The escape hatch is <code>declare global</code>, which pushes you back out into global scope from inside a module:</p>
<pre><code class="language-ts">// globals.d.ts
import type { User } from './types'; // ← this alone makes the file a module

declare global {
  interface Window {
    __APP_STATE__: { user: User }; // ✅ merges again
  }
}

export {};
</code></pre>
<p><code>declare global</code> is the global-scope sibling of <code>declare module 'specifier'</code>. Same job: name the declaration space you want to open.</p>
<p><strong>2. Module augmentation.</strong> The declaring file explicitly names the <em>module</em> scope it wants to reopen:</p>
<pre><code class="language-ts">// express.d.ts — you never touch Express' source
import 'express'; // this file is a module...

declare module 'express-serve-static-core' {
  interface Request {
    currentUser?: User; // ...but this block lands in Express' declaration space
  }
}

// now req.currentUser type-checks in every handler in your app
</code></pre>
<p><code>declare module 'specifier'</code> is the actual mechanism. Without it, a file cannot reach into another module's types — which is the right design. Otherwise any file in your repo could silently mutate any type in any other file.</p>
<p>This is how you extend <code>next-auth</code>'s <code>Session</code>, add a field to Express' <code>Request</code>, or teach <code>fastify</code> about your decorators. It's also why "just declare it again" never works when people try it in a normal <code>.ts</code> file.</p>
<p>The trade-off: an interface is <em>open</em>. Anyone who can name your module can add to it, and you'll only find out at the call site. That openness is the exact thing <code>type</code> gives up.</p>
<hr />
<h2>2. Types (and the fact they can't be re-declared)</h2>
<pre><code class="language-ts">type User = {
  id: string;
  email: string;
};

type User = {
  plan: 'free' | 'pro';
};
// Error: Duplicate identifier 'User'.
</code></pre>
<p>A <code>type</code> is a <strong>name for an expression</strong>. Like <code>const</code> in the value space, the name is bound exactly once. You can't reopen it, which means when you read <code>type User = ...</code>, that is the whole truth about <code>User</code> in that file.</p>
<p>And because it's just a name for an expression, it can name things an interface can't. Here are the six you'll actually use — each one explained properly, because the names are useless until you see the logic.</p>
<hr />
<h3><code>|</code> — a union: "one of these"</h3>
<p>Read <code>|</code> out loud as <strong>"or"</strong>.</p>
<pre><code class="language-ts">type Status = 'draft' | 'published' | 'archived';
</code></pre>
<p>This says: a <code>Status</code> is the string <code>'draft'</code>, <strong>or</strong> the string <code>'published'</code>, <strong>or</strong> the string <code>'archived'</code>. Nothing else.</p>
<p>Not "a string." Those three strings. That's the point.</p>
<pre><code class="language-ts">const a: Status = 'draft';     // ✅
const b: Status = 'published'; // ✅
const c: Status = 'deleted';   // ❌ Type '"deleted"' is not assignable to type 'Status'.
const d: Status = 'Draft';     // ❌ capital D — not one of the three
</code></pre>
<p>You can union anything, not just strings:</p>
<pre><code class="language-ts">type Id = string | number;              // could be either
type MaybeUser = User | null;           // a user, or nothing
type Input = string | string[];         // one item or many
</code></pre>
<p>An interface cannot do this. There's no way to write "an interface that is one of three strings" — an interface describes an <em>object</em>, and <code>'draft'</code> is not an object. That is the single biggest reason <code>type</code> exists.</p>
<hr />
<h3><code>[a, b]</code> — a tuple: an array where each position has a meaning</h3>
<p>A normal array says "many things, all the same type, any number of them":</p>
<pre><code class="language-ts">type Scores = number[];
const s: Scores = [1, 2, 3, 4, 5]; // ✅ any length is fine
</code></pre>
<p>A <strong>tuple</strong> says "exactly this many things, in this order, and each slot has its own type":</p>
<pre><code class="language-ts">type Point = [number, number];

const p: Point = [12, 40];     // ✅ x and y
const q: Point = [12];         // ❌ Source has 1 element but target requires 2.
const r: Point = [12, 40, 99]; // ❌ too many
const t: Point = [12, 'a'];    // ❌ second slot must be a number
</code></pre>
<p>The slots can have <em>different</em> types, which is where it gets useful:</p>
<pre><code class="language-ts">type Result = [Error, null] | [null, User];
</code></pre>
<p>That reads: <strong>either</strong> an error and no user, <strong>or</strong> no error and a user. Never both, never neither. (That's a union of two tuples — you've now stacked two of these ideas.)</p>
<p>You already use tuples every day without noticing. This is what <code>useState</code> returns:</p>
<pre><code class="language-ts">const [count, setCount] = useState(0);
// useState returns [number, (n: number) =&gt; void]
// slot 0 is the value, slot 1 is the setter — different types, fixed order
</code></pre>
<p>That's why destructuring <code>useState</code> gives you correctly-typed variables even though you invented the names <code>count</code> and <code>setCount</code> yourself. TypeScript isn't matching names, it's matching <strong>positions</strong>.</p>
<hr />
<h3><code>&lt;T&gt;</code> — a generic: a type with a hole in it</h3>
<p>This is the one that scares people, and it shouldn't.</p>
<p>A generic is a <strong>type that takes a type as an argument</strong>. It's a function — but instead of taking a value and returning a value, it takes a type and returns a type.</p>
<p>Let's say you keep writing this:</p>
<pre><code class="language-ts">type NullableUser = User | null;
type NullablePost = Post | null;
type NullableComment = Comment | null;
</code></pre>
<p>Same shape three times. The only thing that changes is the type on the left. So punch a hole in it and give the hole a name:</p>
<pre><code class="language-ts">type Nullable&lt;T&gt; = T | null;
//            ↑        ↑
//     the hole    the hole, used
</code></pre>
<p>Now you <em>call</em> it, the same way you'd call a function — but with angle brackets instead of parens:</p>
<pre><code class="language-ts">type NullableUser = Nullable&lt;User&gt;;    // → User | null
type NullablePost = Nullable&lt;Post&gt;;    // → Post | null
type MaybeName = Nullable&lt;string&gt;;     // → string | null
</code></pre>
<p><code>T</code> is just a parameter name. It has no special meaning. You could write <code>Nullable&lt;Whatever&gt; = Whatever | null</code> and it would work identically. <code>T</code> is convention, short for "Type", the way <code>i</code> is convention in a for-loop.</p>
<p>Substitution is the whole mental model. <code>Nullable&lt;User&gt;</code> means: <strong>take the definition, and wherever you see</strong> <code>T</code><strong>, write</strong> <code>User</code> <strong>instead.</strong></p>
<pre><code class="language-ts">Nullable&lt;T&gt;    =  T    | null
Nullable&lt;User&gt; =  User | null
</code></pre>
<p>That's it. That's generics. Every scary-looking generic in the wild is that same substitution, just nested.</p>
<p>You've been using them already:</p>
<pre><code class="language-ts">Array&lt;string&gt;       // an array whose items are strings — same as string[]
Promise&lt;User&gt;       // a promise that will produce a User
Record&lt;string, number&gt;  // an object with string keys and number values
Pick&lt;User, 'email'&gt;     // (section 5) — it takes TWO type arguments
</code></pre>
<hr />
<h3><code>(x) =&gt; y</code> — a function type: the shape of a callable</h3>
<p>You can name the <em>signature</em> of a function, without writing the function.</p>
<pre><code class="language-ts">type Handler = (event: MouseEvent) =&gt; void;
</code></pre>
<p>That reads: <strong>a thing you can call with a</strong> <code>MouseEvent</code><strong>, which gives you back nothing.</strong> (<code>void</code> = "returns nothing useful, ignore the return value.")</p>
<p>Let's say five components all take an <code>onClick</code>. Name the shape once:</p>
<pre><code class="language-ts">type Handler = (event: MouseEvent) =&gt; void;

interface ButtonProps {
  onClick: Handler;
  onHover: Handler;
}

const handleClick: Handler = (event) =&gt; {
  console.log(event.clientX); // ✅ TypeScript knows `event` is a MouseEvent
};
</code></pre>
<p>Notice you never annotated <code>event</code> in <code>handleClick</code>. You didn't have to — the type flowed <em>in</em> from <code>Handler</code>. That's called <strong>contextual typing</strong>, and it's most of why annotating your props properly is worth the effort: you write the type once, and every callback beneath it becomes free.</p>
<hr />
<h3><code>keyof</code> — give me the key names, as a union</h3>
<p><code>keyof</code> takes an object type and hands you back <strong>the list of its property names</strong>, as a union of strings.</p>
<pre><code class="language-ts">interface User {
  id: string;
  email: string;
  createdAt: Date;
}

type UserKey = keyof User;
// → 'id' | 'email' | 'createdAt'
</code></pre>
<p>That's the whole operation. <code>keyof</code> in, union of key names out.</p>
<p>Compare it to something you already know:</p>
<pre><code class="language-ts">Object.keys(user)  // value space, at runtime  → ['id', 'email', 'createdAt']
keyof User         // type space, at compile time → 'id' | 'email' | 'createdAt'
</code></pre>
<p>Same idea, different universe. One gives you an array of strings you can loop over. The other gives you a union of strings the compiler can check against.</p>
<p>Why you'd want it — let's say you're writing a sort function:</p>
<pre><code class="language-ts">const sortBy = (users: User[], key: keyof User) =&gt; { /* ... */ };

sortBy(list, 'email');     // ✅
sortBy(list, 'emial');     // ❌ Argument of type '"emial"' is not assignable
                           //    to parameter of type 'keyof User'.
sortBy(list, 'password');  // ❌ that field doesn't exist on User
</code></pre>
<p>If you'd typed <code>key: string</code>, all three would compile and two would silently sort by nothing. <code>keyof</code> means <strong>"a real key of this thing, not just any old string."</strong> And you get autocomplete on the argument.</p>
<hr />
<h3><code>User['id']</code> — indexed access: give me the type <em>of</em> that property</h3>
<p>Square brackets, but in the type space.</p>
<pre><code class="language-ts">interface User {
  id: string;
  createdAt: Date;
}

type Id = User['id'];               // → string
type Timestamp = User['createdAt']; // → Date
</code></pre>
<p>Read it exactly like property access, because it <em>is</em> property access — just one universe up:</p>
<pre><code class="language-ts">user['id']   // value space → the actual id, "abc-123"
User['id']   // type space  → the TYPE of that field, string
</code></pre>
<p>Why bother, when you could just write <code>string</code>?</p>
<p>Let's say six months from now you brand your IDs for safety (section 15):</p>
<pre><code class="language-ts">interface User {
  id: UserId;  // ← changed from string
  createdAt: Date;
}
</code></pre>
<p>Every place that wrote <code>type Id = User['id']</code> <strong>updates itself</strong>. Every place that hand-wrote <code>string</code> is now quietly, invisibly wrong. That's the whole game: <strong>derive, don't duplicate.</strong></p>
<p>And now the part that pays off later — you can index with a <strong>union</strong> of keys, and you get back a <strong>union</strong> of the types:</p>
<pre><code class="language-ts">type Values = User['id' | 'createdAt'];  // → string | Date
</code></pre>
<p>Feed it <em>every</em> key at once, and you get every value type:</p>
<pre><code class="language-ts">type AllValues = User[keyof User];  // → string | Date
//                    ↑
//            'id' | 'createdAt'
</code></pre>
<p>Sit with that line for a second, because <strong>it is section 4</strong> — the <code>(typeof X)[keyof typeof X]</code> monster — with the scary part removed. Same two operators, same order. You already understand it.</p>
<hr />
<p>So: an <code>interface</code> can only ever describe <strong>the shape of an object</strong>. A <code>type</code> can describe <em>any</em> type — a union, a tuple, a function, or something computed from another type entirely.</p>
<hr />
<h2>3. interface vs type — the actual decision</h2>
<table>
<thead>
<tr>
<th></th>
<th><code>interface</code></th>
<th><code>type</code></th>
</tr>
</thead>
<tbody><tr>
<td>Re-declarable (merging)</td>
<td>✅ yes</td>
<td>❌ no</td>
</tr>
<tr>
<td>Can be a union / tuple / primitive</td>
<td>❌ no</td>
<td>✅ yes</td>
</tr>
<tr>
<td>Extending</td>
<td><code>extends</code></td>
<td><code>&amp;</code> intersection</td>
</tr>
<tr>
<td>Conflicting members</td>
<td><strong>compile error</strong></td>
<td>silently becomes <code>never</code></td>
</tr>
</tbody></table>
<p>That last row is the one worth internalizing.</p>
<pre><code class="language-ts">interface A { id: string }
interface B extends A { id: number }
// Error: Interface 'B' incorrectly extends 'A'.
//        Type 'number' is not assignable to type 'string'.
</code></pre>
<p>TypeScript stops you. Now the same thing with an intersection:</p>
<pre><code class="language-ts">type A = { id: string };
type B = A &amp; { id: number };

const b: B = { id: ??? }; // id is `string &amp; number` → never. Nothing satisfies it.
</code></pre>
<p>No error at the declaration. The bug is <em>deferred</em> to whoever tries to construct a <code>B</code>, and the error they get ("Type 'string' is not assignable to type 'never'") points at their code, not at yours.</p>
<hr />
<h2>4. <code>(typeof CustomType)[keyof typeof CustomType]</code> — the one that makes it click</h2>
<p>Read it inside out, and keep the two worlds separate: <code>CUSTOM_TYPE</code> the value (a real object at runtime) and the type it happens to have. Every operator here just moves between those worlds.</p>
<p>Start with the value:</p>
<pre><code class="language-ts">const CUSTOM_TYPE = {
  FIRST: 'first',
  SECOND: 'second',
  THIRD: 'third',
} as const
</code></pre>
<p><code>typeof CUSTOM_TYPE</code> — "give me the type of that value". This is the type-level <code>typeof</code>, nothing to do with the JS <code>typeof</code> that returns <code>'string'</code>. It's the bridge from value-world to type-world.</p>
<pre><code class="language-typescript">// typeof CUSTOM_TYPE
{
  readonly FIRST: 'first'
  readonly SECOND: 'second'
  readonly THIRD: 'third'
}
</code></pre>
<p><code>keyof typeof CUSTOM_TYPE</code> — <code>keyof</code> takes a type and hands back a union of its keys:</p>
<pre><code class="language-ts">'FIRST' | 'SECOND' | 'THIRD'
</code></pre>
<p><code>(typeof X)[keyof typeof X]</code> — an indexed access. Same syntax as <code>obj[key]</code> in JS, but at the type level: index a type with a key, get the value type at that key.</p>
<p>I like to imagine <code>typeof X</code> as an <em>object</em> with keys. and <code>keyof</code> that object provides those <em>keys</em>. this reads like <code>obj[key]</code></p>
<pre><code class="language-typescript">type A = (typeof CUSTOM_TYPE)['FIRST']  // 'first'
</code></pre>
<p>The trick is that indexing with a <em>union</em> of keys gives you a union of the value types:</p>
<pre><code class="language-typescript">(typeof CUSTOM_TYPE)['FIRST' | 'SECOND' | 'THIRD']
// → 'first' | 'second' | 'third'
</code></pre>
<p>So the whole thing reads: "the union of all value types in <code>CUSTOM_TYPE</code>." In plain JS terms, it's <code>Object.values()</code>, at the type level.</p>
<p>The parens around <code>typeof CUSTOM_TYPE</code> are only there for precedence — without them <code>typeof X[...]</code> parses as <code>typeof (X[...])</code>, which is a different thing entirely. That's a big part of why the line looks so dense: you're reading <code>X</code> twice, once inside parens and once inside <code>keyof</code>.</p>
<p>If it helps, break it up — the meaning survives intact:</p>
<pre><code class="language-ts">type CustomTypeMap = typeof CUSTOM_TYPE
type CustomTypeKey = keyof CustomTypeMap              // 'FIRST' | 'SECOND' | 'THIRD'
export type CustomType = CustomTypeMap[CustomTypeKey] // 'first' | 'second' | 'third'
</code></pre>
<p>I'd argue that's better code anyway — the intermediate names are free and they document the two halves.</p>
<p>Why bother at all: it keeps the type derived from the object. Add a fourth member to <code>CUSTOM_TYPE</code> and the union updates itself, and every <code>switch</code> that was exhaustive now fails to compile until you handle the new case. Write the union by hand and it drifts.</p>
<p>There's also a <strong>generic helper</strong> worth stashing, since you'll hit this pattern constantly:</p>
<pre><code class="language-ts">type ValueOf&lt;T&gt; = T[keyof T]

export type CustomType = ValueOf&lt;typeof CUSTOM_TYPE&gt;
</code></pre>
<p>Same thing, reads like English.</p>
<h3>Same trick, other shapes</h3>
<pre><code class="language-ts">const ROUTES = ['/home', '/settings', '/billing'] as const;
type Route = (typeof ROUTES)[number]; // "/home" | "/settings" | "/billing"
</code></pre>
<p>An array is an object whose keys are numbers, so indexing its type with <code>number</code> gives you the union of its elements. Same mechanism.</p>
<hr />
<h2>5. Pick, Omit</h2>
<p>Let's say you have this:</p>
<pre><code class="language-ts">interface User {
  id: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}
</code></pre>
<p>Your API route must never leak <code>passwordHash</code>. Don't hand-write a second interface — it will drift from the first one within a week.</p>
<pre><code class="language-ts">type PublicUser = Omit&lt;User, 'passwordHash'&gt;;
// { id: string; email: string; createdAt: Date }

type UserCredentials = Pick&lt;User, 'email' | 'passwordHash'&gt;;
// { email: string; passwordHash: string }
</code></pre>
<p><code>Pick</code> = keep these keys. <code>Omit</code> = drop these keys. They're <strong>derivations</strong>: add a field to <code>User</code> tomorrow and <code>PublicUser</code> gets it for free, while <code>UserCredentials</code> stays exactly as narrow as it was.</p>
<p>The subtle difference: <code>Pick&lt;User, 'emial'&gt;</code> is a <strong>compile error</strong> (the key must exist), while <code>Omit&lt;User, 'emial'&gt;</code> silently does nothing — <code>Omit</code>'s key parameter isn't constrained to <code>keyof T</code>. So typos in <code>Omit</code> are invisible. Prefer <code>Pick</code> when the safe list is short; use <code>Omit</code> when the removal list is short and <em>review it</em>.</p>
<p>Neighbours you'll reach for constantly:</p>
<pre><code class="language-ts">Partial&lt;User&gt;            // every field optional  — patch payloads
Required&lt;User&gt;           // every field required
Readonly&lt;User&gt;           // every field readonly
Record&lt;Status, string[]&gt; // { draft: string[]; published: string[]; ... }
ReturnType&lt;typeof fn&gt;    // what fn returns
Parameters&lt;typeof fn&gt;    // its args as a tuple
Awaited&lt;ReturnType&lt;typeof fetchUser&gt;&gt; // unwrap the Promise
Exclude&lt;Status, 'archived'&gt;           // remove members from a union
Extract&lt;Status, 'draft' | 'x'&gt;        // keep members of a union
NonNullable&lt;T&gt;                        // T minus null and undefined
</code></pre>
<p>Note <code>Omit</code>/<code>Pick</code> work on object types; <code>Exclude</code>/<code>Extract</code> work on unions. Same idea, different universe of "members".</p>
<hr />
<h2>6. <code>interface extends otherInterface</code></h2>
<p>Let's say every entity in your DB has the same audit columns:</p>
<pre><code class="language-ts">interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

interface User extends Entity {
  email: string;
}

interface Document extends Entity {
  title: string;
  ownerId: User['id']; // indexed access — if id becomes a branded type, this follows
}
</code></pre>
<p><code>extends</code> here means "has at least everything <code>Entity</code> has, plus these." It's not inheritance in the OOP sense — there's no runtime, no prototype chain. It's a <strong>subtyping assertion</strong>, checked at compile time and erased.</p>
<p>You can extend multiple interfaces, and you can extend a <code>type</code> as long as it's object-shaped:</p>
<pre><code class="language-ts">type Timestamped = { createdAt: Date };
interface Post extends Timestamped, Entity { title: string }
</code></pre>
<p>And remember section 3: if <code>Post</code> declares a member that conflicts with <code>Entity</code>, you get an error at the declaration — which is exactly what you want.</p>
<hr />
<h2>7. <code>A &amp; B</code> and <code>A | B</code></h2>
<pre><code class="language-ts">type Draggable = { onDrag: () =&gt; void };
type Resizable = { onResize: () =&gt; void };

type Widget = Draggable &amp; Resizable; // must have BOTH methods
type Event = Draggable | Resizable;  // has at LEAST one of them
</code></pre>
<p>Here's the part that confuses people: <code>&amp;</code> <strong>(AND) gives you a bigger object;</strong> <code>|</code> <strong>(OR) gives you a smaller usable object.</strong></p>
<p>That's because a type is a <em>set of possible values</em>.</p>
<ul>
<li><p><code>A &amp; B</code> = the set of values that are simultaneously an <code>A</code> and a <code>B</code>. To qualify, a value needs <em>more</em> properties. <strong>More constraints, fewer values.</strong></p>
</li>
<li><p><code>A | B</code> = the set of values that are an <code>A</code> <em>or</em> a <code>B</code>. More values qualify — so TypeScript knows <em>less</em> about any given one.</p>
</li>
</ul>
<pre><code class="language-ts">const handle = (e: Draggable | Resizable) =&gt; {
  e.onDrag(); // Error: Property 'onDrag' does not exist on type 'Resizable'.
};
</code></pre>
<p>Of course it doesn't. TypeScript doesn't know <em>which</em> one you have. You only get to touch the properties that exist on <strong>every</strong> member of the union — until you narrow it (section 10).</p>
<hr />
<h2>8. The mutually-exclusive props pattern (<code>never</code> as a "you may not pass this")</h2>
<p>Let's say you have a delete function that takes either one id, or many. Naively:</p>
<pre><code class="language-ts">interface DeleteArgs {
  id?: string;
  ids?: string[];
}

deleteItems({});                          // 🙃 valid, deletes nothing
deleteItems({ id: '1', ids: ['2', '3'] }); // 🙃 valid, which wins?
</code></pre>
<p>The type says "both optional" when you meant "exactly one." Encode the <em>logic</em>:</p>
<pre><code class="language-ts">type DeleteArgs =
  | { id: string;  ids?: never }
  | { id?: never;  ids: string[] };

deleteItems({ id: '1' });              // ✅
deleteItems({ ids: ['1', '2'] });      // ✅
deleteItems({ id: '1', ids: ['2'] });  // ❌ Type 'string[]' is not assignable to type 'never'.
deleteItems({});                       // ❌ neither branch satisfied
</code></pre>
<p>Why does this work? <code>never</code> is the type with <strong>zero possible values</strong> (section 12). So <code>ids?: never</code> reads as: <em>this key is optional, and if you do provide it, there is no value on earth that will type-check.</em> It's a compile-time "this key must be absent."</p>
<p><strong>Important detail:</strong> the <code>?</code> is load-bearing. <code>{ id: string; ids: never }</code> would make the branch <em>impossible to satisfy</em>, because <code>ids</code> would be a required property that can never be given a value. Optional-<code>never</code> = "must be absent." Required-<code>never</code> = "this object cannot exist."</p>
<p>Inside the function, narrow with a truthiness check or a discriminant:</p>
<pre><code class="language-ts">const deleteItems = (args: DeleteArgs) =&gt; {
  const idList = args.ids ?? [args.id];
  // ...
};
</code></pre>
<p>If you find yourself writing many of these, add an explicit <strong>discriminant</strong> instead — it's cheaper for the compiler and clearer for humans:</p>
<pre><code class="language-ts">type DeleteArgs =
  | { mode: 'single'; id: string }
  | { mode: 'bulk';   ids: string[] };
</code></pre>
<p>Now <code>mode</code> is a tag. TypeScript builds an internal map from <code>'single' → first member</code>, <code>'bulk' → second member</code>, so narrowing becomes a lookup rather than an assignability check against each shape. That's the "cheaper for the compiler" part.</p>
<p>Then check like so:</p>
<pre><code class="language-ts">const remove = (args: DeleteArgs) =&gt; {
  if (args.mode === 'single') {
    return deleteOne(args.id);
  }
  return deleteMany(args.ids);  // narrowed to 'bulk' by elimination
};
</code></pre>
<hr />
<h2>9. <code>as const</code> — and what happens without it</h2>
<p>Let's say you write a config object:</p>
<pre><code class="language-ts">const config = {
  env: 'production',
  retries: 3,
  hosts: ['a.com', 'b.com'],
};
</code></pre>
<p>What did TypeScript infer?</p>
<pre><code class="language-ts">// {
//   env: string;          ← not "production"
//   retries: number;      ← not 3
//   hosts: string[];      ← mutable, element type string
// }
</code></pre>
<p>This is <strong>widening</strong>. Because object properties are mutable, TypeScript assumes you might later write <code>config.env = 'staging'</code>, so it widens the literal <code>'production'</code> up to <code>string</code>. Which means this fails:</p>
<pre><code class="language-ts">type Env = 'production' | 'staging';
const env: Env = config.env; // Error: Type 'string' is not assignable to type 'Env'.
</code></pre>
<p>Now add <code>as const</code>:</p>
<pre><code class="language-ts">const config = {
  env: 'production',
  retries: 3,
  hosts: ['a.com', 'b.com'],
} as const;

// {
//   readonly env: "production";
//   readonly retries: 3;
//   readonly hosts: readonly ["a.com", "b.com"];
// }
</code></pre>
<p><code>as const</code> says: <strong>"nothing here will ever be reassigned, so don't widen anything."</strong> Every property becomes <code>readonly</code>, every literal stays literal, every array becomes a <code>readonly</code> tuple.</p>
<p>Two consequences worth knowing:</p>
<ol>
<li><p>It's what makes section 4's <code>(typeof X)[keyof typeof X]</code> produce <code>"draft" | "published"</code> instead of a useless <code>string</code>.</p>
</li>
<li><p><code>readonly string[]</code> is <strong>not</strong> assignable to <code>string[]</code>. If a function takes <code>string[]</code>, passing <code>config.hosts</code> is an error. Widen the parameter to <code>readonly string[]</code> — you almost never actually needed a mutable array.</p>
</li>
</ol>
<h3>Its better half: <code>satisfies</code></h3>
<p><code>as const</code> and a type annotation each do half the job, and they get in each other's way.</p>
<p><strong>Annotate, and you get checking — but you lose the literals:</strong></p>
<pre><code class="language-ts">const ROUTES: Record&lt;string, `/${string}`&gt; = {
  home: '/home',
  billing: 'billing', // ❌ caught: no leading slash. Good.
};

ROUTES.home; // `/${string}` — not '/home'. You were told the type; TS forgot the value.
</code></pre>
<p>An annotation is a command: <em>you are this type now</em>. TypeScript takes you at your word and stops looking at what you actually wrote.</p>
<p><strong>Use</strong> <code>as const</code><strong>, and you keep the literals — but nothing is checked:</strong></p>
<pre><code class="language-ts">const ROUTES = {
  home: '/home',
  billing: 'billing', // ✅ compiles. Nobody asked whether this was a valid path.
} as const;

ROUTES.home; // '/home'. Exactly what you wrote.
</code></pre>
<p><code>satisfies</code> <strong>is the missing piece.</strong> It checks the value against a constraint and then throws the constraint away — it never becomes the variable's type:</p>
<pre><code class="language-ts">const ROUTES = {
  home: '/home',
  billing: '/billing',
} as const satisfies Record&lt;string, `/${string}`&gt;;
</code></pre>
<p>Two jobs, one line:</p>
<ul>
<li><p><code>as const</code> → freezes the literals. <code>ROUTES.home</code> is <code>'/home'</code>, and <code>keyof typeof ROUTES</code> is <code>'home' | 'billing'</code>, not <code>string</code>.</p>
</li>
<li><p><code>satisfies</code> → audits them. Drop the leading slash and the build fails.</p>
</li>
</ul>
<p>Which means this now works, and is trustworthy:</p>
<pre><code class="language-ts">type Route = (typeof ROUTES)[keyof typeof ROUTES];
// '/home' | '/billing'
</code></pre>
<p>With the annotation, <code>Route</code> would have been <code>`/${string}`</code> — useless as a union. With <code>as const</code> alone, a typo would have quietly become a member of it.</p>
<p><strong>Rule of thumb:</strong> <code>an annotation</code> <em>declares</em> a type. <code>as const</code> <em>preserves</em> one. <code>satisfies</code> <em>verifies</em> one without replacing it. You want the last two together.</p>
<p>One caveat worth knowing: <code>satisfies</code> on its own sometimes preserves literals too, depending on the constraint. Don't rely on it. If you want literals, say <code>as const</code> — then the behavior is a rule, not a coincidence.</p>
<hr />
<h2>10. Type guards (<code>value is Type</code>) — teaching the compiler what you know</h2>
<p>Let's say you're handling something from a <code>catch</code> or a <code>JSON.parse</code>. You know it's a <code>User</code> because you checked. TypeScript doesn't.</p>
<p>A <strong>type predicate</strong> is a function whose return type is <code>arg is Type</code>. When it returns <code>true</code>, the compiler narrows the argument in the calling scope.</p>
<pre><code class="language-ts">const isUser = (value: unknown): value is User =&gt;
  typeof value === 'object' &amp;&amp;
  value !== null &amp;&amp;
  'id' in value &amp;&amp;
  typeof (value as User).id === 'string';

const handle = (payload: unknown) =&gt; {
  if (!isUser(payload)) return;
  payload.email; // ✅ payload is User in here
};
</code></pre>
<p>The <code>is</code> is a <strong>promise you are making to the compiler</strong>. If your check is wrong, TypeScript will happily believe you and you'll crash at runtime. It's a controlled <code>as</code>, not a proof.</p>
<h3>The narrowings you get for free</h3>
<pre><code class="language-ts">if (typeof x === 'string')      // primitives
if (Array.isArray(x))           // arrays
if (x instanceof Error)         // classes
if ('id' in x)                  // the `in` operator, for object unions
if (x !== null)                 // truthiness / null checks
</code></pre>
<h3>Discriminated unions — the pattern to reach for first</h3>
<p>Give every member of a union a shared literal field. TypeScript narrows on it automatically, and you write zero guards:</p>
<pre><code class="language-ts">type Result =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

const render = (result: Result) =&gt; {
  switch (result.status) {
    case 'loading': return spinner();
    case 'success': return view(result.data);   // ✅ .data exists only here
    case 'error':   return alert(result.error); // ✅ .error exists only here
  }
};
</code></pre>
<p>This is 90% of the value of TypeScript in one construct. It makes illegal states <em>unrepresentable</em> — you cannot have a <code>Result</code> that is both loading and holding data.</p>
<hr />
<h2>11. Recursive types</h2>
<p>For example, you have a comment tree, a nav menu, a file system:</p>
<pre><code class="language-ts">interface Comment {
  id: string;
  body: string;
  replies: Comment[]; // fine — self-reference through a property
}
</code></pre>
<p>Recursion gets powerful when combined with mapped types.</p>
<p>Here's a shape you've written a hundred times:</p>
<pre><code class="language-ts">type User = {
  id: string;
  profile: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
};
</code></pre>
<p>Now write the type for a PATCH payload — the same shape, but every field optional, because the client only sends what changed.</p>
<p>Your first instinct is the built-in:</p>
<pre><code class="language-ts">type PatchUser = Partial&lt;User&gt;;
</code></pre>
<p>Hover it, and you get this:</p>
<pre><code class="language-ts">{
  id?: string;
  profile?: {
    name: string;                            // ← still required
    address: { city: string; zip: string };  // ← still required
  };
}
</code></pre>
<p>It made the <em>top-level</em> keys optional and stopped. Which means the payload you actually want to send doesn't compile:</p>
<pre><code class="language-ts">const patch: PatchUser = {
  profile: { name: 'Ada' },
};
// ❌ Property 'address' is missing
</code></pre>
<p>To rename a user you'd have to resend their entire address. That's the problem. Now let's look at why <code>Partial</code> behaves this way, because the fix falls out of it.</p>
<h2><code>Partial</code> is one line, and it only loops once</h2>
<p>This is the whole thing, straight from the standard library:</p>
<pre><code class="language-ts">type Partial&lt;T&gt; = { [K in keyof T]?: T[K] };
</code></pre>
<p>That's a <strong>mapped type</strong>. Read it as a for-loop that builds a new object type:</p>
<pre><code class="language-plaintext">for each key K in keyof T:
  emit key K, but optional (?)
  give it the type T[K] — whatever it was before
</code></pre>
<p>Applied to <code>User</code>, the loop runs twice:</p>
<table>
<thead>
<tr>
<th>K</th>
<th><code>T[K]</code></th>
<th>emits</th>
</tr>
</thead>
<tbody><tr>
<td><code>'id'</code></td>
<td><code>string</code></td>
<td><code>id?: string</code></td>
</tr>
<tr>
<td><code>'profile'</code></td>
<td><code>{ name: ...; address: ... }</code></td>
<td><code>profile?: { name: ...; address: ... }</code></td>
</tr>
</tbody></table>
<p>Look at the second row. It made the <code>profile</code> <strong>key</strong> optional. But the <strong>value</strong> — that nested object — was copied across verbatim. <code>T[K]</code> hands it over untouched. Nobody ever went inside it.</p>
<p>That's the bug in one sentence: <strong>the loop runs once, at one level.</strong> The nested object is just a value being copied, not a thing being processed.</p>
<h2>The fix: process the value instead of copying it</h2>
<p>So don't copy <code>T[K]</code>. Run it through the same transformation again:</p>
<pre><code class="language-ts">type DeepPartial&lt;T&gt; = {[K in keyof T]?: DeepPartial&lt;T[K]&gt;};
//                                       ^^^^^^^^^^^^^^^^^
//  instead of T[K], it's DeepPartial&lt;T[K]&gt;
</code></pre>
<p>One token changed. <code>T[K]</code> became <code>DeepPartial&lt;T[K]&gt;</code>.</p>
<p>That's the recursion, and it's not a clever trick — it's the obvious move once you see that <code>T[K]</code> was the place the descent <em>should</em> have happened and didn't.</p>
<h2>But now it never stops</h2>
<p>Run <code>DeepPartial&lt;User&gt;</code> with the definition above and follow the <code>id</code> key:</p>
<ul>
<li><p><code>id</code>'s type is <code>string</code></p>
</li>
<li><p>so we call <code>DeepPartial&lt;string&gt;</code></p>
</li>
<li><p>which expands to <code>{ [K in keyof string]?: DeepPartial&lt;string[K]&gt; }</code></p>
</li>
<li><p>and <code>keyof string</code> is... <code>'charAt' | 'slice' | 'length' | 'toUpperCase' | ...</code></p>
</li>
</ul>
<p>You get an object with optional <code>charAt</code> and <code>toUpperCase</code> properties. Which is not a string, and not what anyone wanted.</p>
<p>The loop needs a floor. <strong>Primitives are the floor</strong> — there's nothing inside a <code>string</code> to make optional, so when you hit one, hand it back and stop:</p>
<pre><code class="language-ts">type DeepPartial&lt;T&gt; = T extends object
  ? { [K in keyof T]?: DeepPartial&lt;T[K]&gt; }   // it's an object — go in
  : T;                                        // it's a primitive — done
</code></pre>
<p>That conditional is the base case. Every recursive type needs one, and it's almost always "am I still looking at something with keys?"</p>
<h2>Trace it end to end</h2>
<p><code>DeepPartial&lt;User&gt;</code>. <code>User</code> is an object, so loop over its keys:</p>
<p><code>id</code> → <code>DeepPartial&lt;string&gt;</code> → <code>string</code> is not an object → returns <code>string</code>. Emits <code>id?: string</code>. ✅ floor reached.</p>
<p><code>profile</code> → <code>DeepPartial&lt;{ name; address }&gt;</code> → that <em>is</em> an object → loop again:</p>
<p>    <code>name</code> → <code>DeepPartial&lt;string&gt;</code> → <code>string</code>. ✅ floor.</p>
<p>    <code>address</code> → <code>DeepPartial&lt;{ city; zip }&gt;</code> → object → loop again:</p>
<p>        <code>city</code> → <code>string</code>. ✅ floor.         <code>zip</code> → <code>string</code>. ✅ floor.</p>
<p>Every branch bottoms out at a primitive. Unwind, and every <code>?</code> that got emitted on the way down is still there:</p>
<pre><code class="language-ts">type PatchUser = DeepPartial&lt;User&gt;;
// {
//   id?: string;
//   profile?: {
//     name?: string;
//     address?: {
//       city?: string;
//       zip?: string;
//     };
//   };
// }
</code></pre>
<p>And the payload from the top now compiles:</p>
<pre><code class="language-ts">const patch: PatchUser = {
  profile: { name: 'Ada' },   // ✅
};

const deep: PatchUser = {
  profile: { address: { city: 'Bucharest' } },  // ✅ two levels down, one field
};
</code></pre>
<p>Add a fifth level of nesting to <code>User</code> tomorrow and you change nothing. The type follows the shape wherever it goes — that's what you bought.</p>
<h2>The gotcha nobody warns you about</h2>
<p><code>T extends object</code> is broader than "plain object." It's true for <strong>arrays, functions,</strong> <code>Date</code><strong>,</strong> <code>Map</code><strong>, class instances</strong> — everything that isn't a primitive. So <code>DeepPartial</code> marches straight into them:</p>
<pre><code class="language-ts">type T1 = DeepPartial&lt;Date&gt;;
// { toISOString?: () =&gt; string; getTime?: () =&gt; number; ... }
// A Date with optional methods. It is no longer a Date.

type T2 = DeepPartial&lt;string[]&gt;;
// { length?: number; push?: ...; map?: ... }
// It is no longer an array.
</code></pre>
<p>The floor is too low. Raise it — anything you don't want to descend into becomes part of the base case:</p>
<pre><code class="language-ts">type Primitive = string | number | boolean | bigint | symbol | null | undefined;

type DeepPartial&lt;T&gt; = T extends Primitive | Date | RegExp
  ? T                                          // atomic — return as-is
  : T extends ReadonlyArray&lt;infer U&gt;
    ? ReadonlyArray&lt;DeepPartial&lt;U&gt;&gt;            // array — recurse on the element type
    : T extends object
      ? { [K in keyof T]?: DeepPartial&lt;T[K]&gt; } // plain object — recurse on values
      : T;
</code></pre>
<p>Order matters: conditionals resolve top-down, first match wins, so the escape hatches sit above the general object case.</p>
<p>Whether arrays should be descended into at all is a judgment call. For a PATCH payload, arrays are usually atomic — you replace the whole list or you omit it, you don't partially patch element 3. In that case:</p>
<pre><code class="language-ts">: T extends ReadonlyArray&lt;unknown&gt;
  ? T   // leave it alone
</code></pre>
<h2>Same skeleton, different transformation</h2>
<p>Once the pattern is in your hands — <em>conditional for the floor, mapped type for the descent,</em> <code>T[K]</code> <em>for the recursion</em> — you get the whole family by changing one token:</p>
<pre><code class="language-ts">type DeepReadonly&lt;T&gt; = T extends object
  ? { readonly [K in keyof T]: DeepReadonly&lt;T[K]&gt; }  // add readonly
  : T;

type DeepRequired&lt;T&gt; = T extends object
  ? { [K in keyof T]-?: DeepRequired&lt;T[K]&gt; }         // -? strips optionality
  : T;

type DeepNullable&lt;T&gt; = T extends object
  ? { [K in keyof T]: DeepNullable&lt;T[K]&gt; | null }    // union in a null
  : T;
</code></pre>
<p>What you need to know before you start applying this everywhere:</p>
<ul>
<li><p>Recursion is depth-limited (~50 levels) — deep recursive <em>conditional</em> types can hit "Type instantiation is excessively deep and possibly infinite."</p>
</li>
<li><p>Heavy recursive types are a real compile-time cost. If your IDE gets sluggish, look here first.</p>
</li>
</ul>
<hr />
<h2>12. <code>any</code> vs <code>unknown</code> vs <code>never</code></h2>
<p>Think of a type as a set of values:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Set</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>unknown</code></td>
<td>all values</td>
<td>"Something's here. I don't know what."</td>
</tr>
<tr>
<td><code>never</code></td>
<td>no values</td>
<td>"Nothing can be here. Unreachable."</td>
</tr>
<tr>
<td><code>any</code></td>
<td>not a set</td>
<td>"Stop checking."</td>
</tr>
</tbody></table>
<h3><code>any</code> is BAD</h3>
<pre><code class="language-ts">const data: any = await res.json();

data.user.profile.nmae;  // no error (typo)
data.foo();              // no error
data + 1;                // no error

const email: string = data.user;  // no error — `any` just became `string`
</code></pre>
<p>That last line is the damage. <code>any</code> doesn't stay put. It flows through assignments and return values into modules that never mentioned <code>any</code>. One <code>any</code> at an API boundary can hollow out a whole feature.</p>
<h3><code>unknown</code> is the honest version</h3>
<pre><code class="language-ts">const data: unknown = await res.json();

data.user;  // ❌ 'data' is of type 'unknown'
</code></pre>
<p>Anything can go <em>into</em> <code>unknown</code>. Nothing comes <em>out</em> until you prove what it is — because as far as TypeScript knows, <code>data</code> could be <code>null</code>, or <code>42</code>, and neither has a <code>.user</code>.</p>
<p><strong>Narrowing</strong> is that proof. You write a runtime check; TypeScript watches it and gives you a better type inside the branch:</p>
<pre><code class="language-ts">const value: unknown = JSON.parse(raw);

if (typeof value === "string") {
  value.toUpperCase();  // ✅ inside here, value is `string`
}

value.toUpperCase();    // ❌ outside, it's `unknown` again
</code></pre>
<p>The check is what buys the type. No check, no access.</p>
<p>For object shapes, write the check once and label it with <code>is</code>:</p>
<pre><code class="language-ts">type User = { id: string; email: string };

const isUser = (v: unknown): v is User =&gt;
  typeof v === "object" &amp;&amp; v !== null &amp;&amp;
  typeof (v as User).id === "string" &amp;&amp;
  typeof (v as User).email === "string";

const data: unknown = await res.json();

if (!isUser(data)) throw new Error("Bad payload");

data.email;  // ✅ data is `User` from here on
</code></pre>
<p><code>any</code> deletes the check. <code>unknown</code> demands it. Same runtime risk either way — opposite compile-time posture. That's why <code>unknown</code> belongs at every boundary: <code>fetch</code>, <code>JSON.parse</code>, <code>localStorage</code>, <code>postMessage</code>, <code>catch (e: unknown)</code>.</p>
<p><code>unknown</code> in, guard, <code>User</code> out. (Which is exactly what Zod automates — section 15.)</p>
<h3><code>never</code> is a proof, not a mistake</h3>
<p><code>never</code> is where a value is impossible:</p>
<pre><code class="language-ts">const fail = (msg: string): never =&gt; { throw new Error(msg); };  // never returns
type Impossible = string &amp; number;                               // empty set
</code></pre>
<p>Its killer app is exhaustiveness. Because <code>never</code> is the empty set, <em>nothing</em> is assignable to it — so a function that demands a <code>never</code> argument only compiles when you've genuinely run out of cases:</p>
<pre><code class="language-ts">type Result =
  | { status: "loading" }
  | { status: "success"; data: Data }
  | { status: "error"; error: string };

const assertNever = (value: never): never =&gt; {
  throw new Error(`Unhandled: ${JSON.stringify(value)}`);
};

const render = (result: Result) =&gt; {
  switch (result.status) {
    case "loading": return spinner();
    case "success": return view(result.data);
    case "error":   return alert(result.error);
    default:        return assertNever(result);  // ✅ compiles
  }
};
</code></pre>
<p>Each <code>case</code> narrows one variant away. By <code>default</code>, all three are gone and <code>result</code> is <code>never</code> — which is the only thing <code>assertNever</code> accepts.</p>
<p>Now add <code>{ status: "idle" }</code> to <code>Result</code>. In <code>default</code>, <code>result</code> is <code>{ status: "idle" }</code>, not <code>never</code>, and the build breaks — right at the switch you forgot to update.</p>
<p>That's the whole point: a compile error at every place you need to think.</p>
<hr />
<h2>13. Why enums are bad</h2>
<pre><code class="language-ts">enum Status {
  Draft = 'draft',
  Published = 'published',
}
</code></pre>
<h3>They don't disappear</h3>
<p>Every other type construct is <em>erased</em> at compile time. Enums are not. They emit runtime JavaScript:</p>
<pre><code class="language-js">var Status;
(function (Status) {
    Status["Draft"] = "draft";
    Status["Published"] = "published";
})(Status || (Status = {}));
</code></pre>
<p>That's an IIFE that mutates an object. Bundlers see a function call with side effects on a shared binding and, conservatively, <strong>keep it</strong> — even if you imported the enum only to reference one member in a type position. You now ship runtime code for something you used as a type. Multiply by every enum in a shared <code>types.ts</code> and it adds up.</p>
<p>Numeric enums are worse: they emit a <strong>reverse mapping</strong> too (<code>Status[0] === "Draft"</code>), so the object is twice the size.</p>
<h3>They're nominal, in a structural language</h3>
<p>TypeScript is structural — a thing that looks like a <code>User</code> <em>is</em> a <code>User</code>. Enums break that rule:</p>
<pre><code class="language-ts">const publish = (status: Status) =&gt; { /* ... */ };

publish('draft');        // ❌ Argument of type '"draft"' is not assignable to 'Status'.
publish(Status.Draft);   // ✅ only this works
</code></pre>
<p>Your enum is now viral: every caller, every test fixture, every mock has to import it. You can't just write the string you can plainly see in the database.</p>
<h3><code>const enum</code> is not the fix</h3>
<p><code>const enum</code> inlines and emits nothing — but it requires whole-program type information, so it <strong>breaks under isolatedModules</strong>, which means it breaks under Babel, esbuild, SWC, and anything Vite-adjacent. TS 5.0 added <code>preserveConstEnums</code>/erasable-syntax pressure precisely because this feature doesn't fit modern build pipelines. And <code>--erasableSyntaxOnly</code> (TS 5.8, for Node's native type-stripping) bans enums outright.</p>
<h3>Just use <code>as const</code> + the section-4 trick</h3>
<pre><code class="language-ts">export const StatusEnum = {
  Draft: 'draft',
  Published: 'published',
} as const;

export type Status = (typeof StatusEnum)[keyof typeof StatusEnum]; // "draft" | "published"
</code></pre>
<p>You get:</p>
<pre><code class="language-ts">publish(StatusEnum.Draft); // ✅ autocomplete, single source of truth
publish('draft');      // ✅ also fine — it's just a string
</code></pre>
<ul>
<li><p>Zero enum machinery. A plain object — tree-shakeable, inlinable, JSON-serializable.</p>
</li>
<li><p>Structural, so raw strings from your DB/API just work.</p>
</li>
<li><p>Same DX: <code>Status.</code> still autocompletes.</p>
</li>
<li><p>The type and the value share a name, so consumers import one symbol.</p>
</li>
</ul>
<p>If you don't even need the runtime object, a bare union is enough: <code>type Status = 'draft' | 'published';</code></p>
<hr />
<h2>14. Zod, and <code>z.infer</code></h2>
<p>Everything above happens at compile time. At runtime, an API response is a lie until you check it.</p>
<pre><code class="language-ts">const res = await fetch('/api/user');
const user: User = await res.json(); // ← this annotation is a wish, not a check
</code></pre>
<p><code>res.json()</code> returns <code>any</code>. You annotated it <code>User</code> and TypeScript relaxed. If the backend renamed <code>email</code> to <code>emailAddress</code> last night, you find out in production.</p>
<p>Zod flips the direction: <strong>define the schema once, derive the type from it.</strong></p>
<pre><code class="language-ts">import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  plan: z.enum(['free', 'pro']),
  createdAt: z.coerce.date(),
  posts: z.array(z.object({ id: z.string(), title: z.string() })).default([]),
});

type User = z.infer&lt;typeof UserSchema&gt;;
// {
//   id: string;
//   email: string;
//   plan: "free" | "pro";
//   createdAt: Date;
//   posts: { id: string; title: string }[];
// }
</code></pre>
<p>Look at what <code>z.infer&lt;typeof UserSchema&gt;</code> is doing — it's exactly section 4. <code>UserSchema</code> is a <strong>value</strong>. <code>typeof UserSchema</code> walks it into the type space. <code>z.infer</code> is a conditional type that reaches inside and pulls out the shape it describes. Same door, same move.</p>
<p>Now the type can't drift from the validation, because <strong>the validation is the source of truth</strong>:</p>
<pre><code class="language-ts">const fetchUser = async (id: string): Promise&lt;User&gt; =&gt; {
  const res = await fetch(`/api/users/${id}`);
  return UserSchema.parse(await res.json()); // throws on the boundary, not 3 layers deep
};
</code></pre>
<p><code>parse</code> throws. <code>safeParse</code> doesn't — it returns a discriminated union, which you already know how to narrow:</p>
<pre><code class="language-ts">const result = UserSchema.safeParse(payload);

if (!result.success) {
  return { error: result.error.flatten() }; // ✅ .error only exists here
}

result.data; // ✅ fully typed User, guaranteed to match at runtime
</code></pre>
<p>One more thing worth knowing: when a schema <strong>transforms</strong> (<code>.coerce</code>, <code>.default</code>, <code>.transform</code>), input and output types differ.</p>
<pre><code class="language-ts">type UserInput  = z.input&lt;typeof UserSchema&gt;;  // createdAt: string | Date, posts optional
type UserOutput = z.output&lt;typeof UserSchema&gt;; // createdAt: Date, posts required
// z.infer === z.output
</code></pre>
<p>Use it at every boundary where data enters your program: HTTP responses, form submissions, <code>process.env</code>, webhook payloads, <code>localStorage</code>. <code>unknown</code> in → <code>parse</code> → typed value out.</p>
<hr />
<h2>15. Some "nice to know"s</h2>
<h3>Generics are just parameters for types</h3>
<p>If a function's return type depends on its input type, that's a generic:</p>
<pre><code class="language-ts">const first = &lt;T,&gt;(items: T[]): T | undefined =&gt; items[0];

first([1, 2, 3]);       // number | undefined
first(['a', 'b']);      // string | undefined
</code></pre>
<p>Constrain them with <code>extends</code> — read it as "T must be at least this":</p>
<pre><code class="language-ts">const getId = &lt;T extends { id: string }&gt;(entity: T) =&gt; entity.id;
</code></pre>
<h3><code>keyof</code> + generics = type-safe property access</h3>
<pre><code class="language-ts">const prop = &lt;T, K extends keyof T&gt;(obj: T, key: K): T[K] =&gt; obj[key];

prop(user, 'email'); // string
prop(user, 'emial'); // ❌ Argument of type '"emial"' is not assignable to 'keyof User'
</code></pre>
<p><code>K extends keyof T</code> constrains the key to actually exist; <code>T[K]</code> returns whatever <em>that specific key's</em> type is. The compiler tracks the relationship between two arguments.</p>
<h3>Conditional types + <code>infer</code></h3>
<pre><code class="language-ts">type Unwrap&lt;T&gt; = T extends Promise&lt;infer U&gt; ? U : T;

type A = Unwrap&lt;Promise&lt;User&gt;&gt;; // User
type B = Unwrap&lt;string&gt;;        // string
</code></pre>
<p><code>T extends X ? Y : Z</code> is a ternary in the type space. <code>infer U</code> is "pattern-match here and give the captured piece a name." That's <code>Awaited</code>, <code>ReturnType</code>, and <code>z.infer</code> — all the same machinery.</p>
<h3>Template literal types</h3>
<pre><code class="language-ts">type Route = `/${string}`;
type EventName = `on${Capitalize&lt;'click' | 'focus'&gt;}`; // "onClick" | "onFocus"
</code></pre>
<p>Strings you can compute with. Combine with key remapping for things like generating getters:</p>
<pre><code class="language-ts">type Getters&lt;T&gt; = {
  [K in keyof T &amp; string as `get${Capitalize&lt;K&gt;}`]: () =&gt; T[K];
};
// Getters&lt;{ id: string }&gt; → { getId: () =&gt; string }
</code></pre>
<h3>Branded types (nominal typing, when you actually want it)</h3>
<p>Let's say <code>userId</code> and <code>postId</code> are both <code>string</code>, and one day you pass the wrong one. TypeScript can't help — structurally, they're identical. So make them different:</p>
<pre><code class="language-ts">type Brand&lt;T, B extends string&gt; = T &amp; { readonly __brand: B };

type UserId = Brand&lt;string, 'UserId'&gt;;
type PostId = Brand&lt;string, 'PostId'&gt;;

const findUser = (id: UserId) =&gt; { /* ... */ };

const postId = 'abc' as PostId;
findUser(postId); // ❌ 'PostId' is not assignable to 'UserId'
</code></pre>
<p>The <code>__brand</code> property doesn't exist at runtime — it's a phantom, purely to make the two types structurally different. Zero cost, real safety at boundaries where IDs get swapped.</p>
<h3><code>!</code> (non-null assertion) is <code>any</code>'s little brother</h3>
<pre><code class="language-ts">const user = users.find((u) =&gt; u.id === id)!; // "trust me"
</code></pre>
<p>You've silenced the compiler, and you get a <code>TypeError: Cannot read properties of undefined</code> instead of a helpful error. Handle the <code>undefined</code>, or throw explicitly so the failure has a message. Reserve <code>!</code> for cases you can prove locally in the next line.</p>
<h3><code>Prettify</code> — the debug helper</h3>
<p>Intersections and mapped types show up in tooltips as unreadable soup. This forces the compiler to flatten them:</p>
<pre><code class="language-ts">type Prettify&lt;T&gt; = { [K in keyof T]: T[K] } &amp; {};

type Ugly = Omit&lt;User, 'id'&gt; &amp; { role: string };  // hover: a mess
type Nice = Prettify&lt;Ugly&gt;;                       // hover: the actual flat object
</code></pre>
<p>Does nothing at runtime, changes nothing semantically, saves your eyes.</p>
<h3>Turn on <code>strict</code></h3>
<p>If <code>strict: true</code> isn't in your <code>tsconfig.json</code>, most of this document is decorative. Also worth adding:</p>
<pre><code class="language-jsonc">{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true, // arr[0] is T | undefined — because it is
    "exactOptionalPropertyTypes": true, // `{ a?: string }` ≠ `{ a: undefined }`
    "noImplicitOverride": true,
    "isolatedModules": true
  }
}
</code></pre>
<p><code>noUncheckedIndexedAccess</code> is the one people fight and then thank you for.</p>
<hr />
<h2>The whole thing, compressed</h2>
<ol>
<li><p><strong>Two universes.</strong> <code>typeof</code> is the door from value space into type space; <code>keyof</code> and indexed access let you walk around once you're through.</p>
</li>
<li><p><strong>Types are sets.</strong> <code>&amp;</code> shrinks the set (bigger objects), <code>|</code> grows it (less you can do). <code>unknown</code> is everything, <code>never</code> is nothing, <code>any</code> is a surrender.</p>
</li>
<li><p><strong>Derive, never duplicate.</strong> <code>Pick</code>, <code>Omit</code>, <code>typeof</code>, <code>z.infer</code> — one source of truth, everything else follows from it.</p>
</li>
<li><p><strong>Make illegal states unrepresentable.</strong> Discriminated unions and optional-<code>never</code> beat a bag of optional booleans, every time.</p>
</li>
<li><p><strong>Validate at the boundary, trust inside it.</strong> <code>unknown</code> in, Zod parse, typed value out.</p>
</li>
<li><p><strong>Prefer things that vanish.</strong> If a construct emits runtime JavaScript for a compile-time concern (looking at you, <code>enum</code>), there's usually a plain object that does it better.</p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[I can't remember SQL syntax]]></title><description><![CDATA[Reading a SELECT



SQL
English reading



SELECT name FROM users
from the users table, give me name for every row


SELECT *
every column


SELECT name, email
just these two columns


SELECT DISTINCT]]></description><link>https://featuringcode.com/i-can-t-remember-sql-syntax</link><guid isPermaLink="true">https://featuringcode.com/i-can-t-remember-sql-syntax</guid><category><![CDATA[SQL]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Databases]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Wed, 08 Jul 2026 17:33:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/415373d8-cbe9-4c6b-84a6-5f2d466abd7d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Reading a SELECT</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>SELECT name FROM users</code></td>
<td>from the <code>users</code> table, give me <code>name</code> for every row</td>
</tr>
<tr>
<td><code>SELECT *</code></td>
<td>every column</td>
</tr>
<tr>
<td><code>SELECT name, email</code></td>
<td>just these two columns</td>
</tr>
<tr>
<td><code>SELECT DISTINCT country FROM users</code></td>
<td>each <code>country</code> value once, no duplicates</td>
</tr>
<tr>
<td><code>SELECT * FROM users u</code></td>
<td>call <code>users</code> "<code>u</code>" for the rest of this query</td>
</tr>
<tr>
<td><code>SELECT price AS cost</code></td>
<td>output this column under the name <code>cost</code></td>
</tr>
<tr>
<td><code>SELECT COUNT(*) FROM users</code></td>
<td>how many rows are in <code>users</code></td>
</tr>
</tbody></table>
<h2>WHERE — which rows to keep</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>WHERE age = 18</code></td>
<td>keep only rows where <code>age</code> is exactly 18</td>
</tr>
<tr>
<td><code>WHERE age != 18</code></td>
<td>keep rows where <code>age</code> is anything but 18</td>
</tr>
<tr>
<td><code>WHERE age &gt;= 18</code></td>
<td>18 or older</td>
</tr>
<tr>
<td><code>WHERE is_verified AND is_active</code></td>
<td>both must be true</td>
</tr>
<tr>
<td><code>WHERE role = 'owner' OR role = 'admin'</code></td>
<td>either one is enough</td>
</tr>
<tr>
<td><code>WHERE NOT is_archived</code></td>
<td>keep rows where <code>is_archived</code> is false</td>
</tr>
<tr>
<td><code>WHERE role IN ('owner', 'admin')</code></td>
<td><code>role</code> is one of this list</td>
</tr>
<tr>
<td><code>WHERE role NOT IN ('member')</code></td>
<td><code>role</code> is none of this list</td>
</tr>
<tr>
<td><code>WHERE age BETWEEN 18 AND 30</code></td>
<td>18 to 30, endpoints included</td>
</tr>
<tr>
<td><code>WHERE name LIKE 'A%'</code></td>
<td><code>name</code> starts with <code>A</code></td>
</tr>
<tr>
<td><code>WHERE name LIKE '%z'</code></td>
<td>ends with <code>z</code></td>
</tr>
<tr>
<td><code>WHERE name LIKE '%an%'</code></td>
<td>contains <code>an</code></td>
</tr>
<tr>
<td><code>WHERE name LIKE 'A_'</code></td>
<td><code>A</code> then exactly one more character</td>
</tr>
<tr>
<td><code>WHERE name ILIKE 'a%'</code></td>
<td>starts with <code>a</code>, ignoring upper/lowercase</td>
</tr>
</tbody></table>
<h2>NULL — the "no value" trap</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>NULL</code></td>
<td>unknown — no value at all (not zero, not "")</td>
</tr>
<tr>
<td><code>WHERE name IS NULL</code></td>
<td>keep rows whose <code>name</code> is empty</td>
</tr>
<tr>
<td><code>WHERE name IS NOT NULL</code></td>
<td>keep rows that have a <code>name</code></td>
</tr>
<tr>
<td><code>WHERE name = NULL</code></td>
<td>✗ always matches nothing — <code>= NULL</code> is never true; use <code>IS NULL</code></td>
</tr>
<tr>
<td><code>COALESCE(name, 'Anon')</code></td>
<td><code>name</code>, or <code>'Anon'</code> when <code>name</code> is NULL (first non-null wins)</td>
</tr>
</tbody></table>
<h2>Sorting &amp; paging</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>ORDER BY created_at</code></td>
<td>sort by <code>created_at</code>, oldest first</td>
</tr>
<tr>
<td><code>ORDER BY created_at DESC</code></td>
<td>newest first</td>
</tr>
<tr>
<td><code>ORDER BY last_name, first_name</code></td>
<td>by <code>last_name</code>, then <code>first_name</code> to break ties</td>
</tr>
<tr>
<td><code>LIMIT 10</code></td>
<td>at most 10 rows</td>
</tr>
<tr>
<td><code>LIMIT 10 OFFSET 20</code></td>
<td>skip 20, then take 10 (page 3)</td>
</tr>
<tr>
<td><code>ORDER BY created_at DESC LIMIT 1</code></td>
<td>the single newest row</td>
</tr>
</tbody></table>
<h2>INSERT / UPDATE / DELETE</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>INSERT INTO users (email, name) VALUES ('mara@x.com', 'Mara')</code></td>
<td>add one user with that email and name (other columns take their defaults)</td>
</tr>
<tr>
<td><code>INSERT INTO users (email) VALUES ('a@x.com'), ('b@x.com')</code></td>
<td>add two users at once</td>
</tr>
<tr>
<td><code>UPDATE users SET name = 'Mara' WHERE id = 5</code></td>
<td>change <code>name</code> to 'Mara', only in the matching row</td>
</tr>
<tr>
<td><code>UPDATE users SET email_verified = true</code></td>
<td>✗ verify <strong>every</strong> user — no <code>WHERE</code>, no mercy</td>
</tr>
<tr>
<td><code>DELETE FROM users WHERE id = 5</code></td>
<td>remove the matching row</td>
</tr>
<tr>
<td><code>DELETE FROM users</code></td>
<td>✗ empty the whole <code>users</code> table</td>
</tr>
<tr>
<td><code>DELETE FROM users WHERE id = 5 RETURNING *</code></td>
<td>delete it, and hand back the row you just removed</td>
</tr>
</tbody></table>
<h2>JOINs — combining two tables (multiple readings)</h2>
<p>Example: <code>users</code> and their <code>documents</code> (<code>documents.owner_id</code> points at <code>users.id</code>; a user can own many documents, or none).</p>
<p>Every join answers one question: <strong>a row found no match — does it survive?</strong> The join word names who survives. A survivor with no partner gets <code>NULL</code> in the other table's columns.</p>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>users JOIN documents ON documents.owner_id = users.id</code></td>
<td>only matched user–document pairs survive</td>
</tr>
<tr>
<td>· "for each document, attach its owner"</td>
<td></td>
</tr>
<tr>
<td>· no match → dropped, from either table</td>
<td></td>
</tr>
<tr>
<td><code>users LEFT JOIN documents ON documents.owner_id = users.id</code></td>
<td><strong>every user survives</strong>; a user with no documents still appears, with <code>NULL</code> in the document columns</td>
</tr>
<tr>
<td>· "all users, plus their docs where they exist"</td>
<td></td>
</tr>
<tr>
<td><code>users RIGHT JOIN documents ON documents.owner_id = users.id</code></td>
<td><strong>every document survives</strong>; a document with no matching user gets <code>NULL</code> in the user columns</td>
</tr>
<tr>
<td>· mirror of LEFT — same as <code>documents LEFT JOIN users</code></td>
<td></td>
</tr>
<tr>
<td><code>users FULL JOIN documents ON documents.owner_id = users.id</code></td>
<td><strong>everybody survives</strong>, from both tables; <code>NULL</code> wherever either side has no match</td>
</tr>
<tr>
<td><code>users CROSS JOIN documents</code></td>
<td>every user paired with every document — all combinations, no matching</td>
</tr>
<tr>
<td><code>employees e JOIN employees m ON e.manager_id = m.id</code></td>
<td>join a table to itself: each employee beside their manager</td>
</tr>
<tr>
<td><code>users LEFT JOIN documents ON documents.owner_id = users.id WHERE documents.id IS NULL</code></td>
<td>the users who own <strong>no</strong> documents — LEFT keeps them all, then keep only the ones whose document side came back <code>NULL</code></td>
</tr>
</tbody></table>
<p>Memory hook: <code>ON</code> decides who matches · the join word decides who survives without a match · <code>NULL</code> fills the missing side.</p>
<p>Reminder: <code>OUTER</code> is an optional word — <code>LEFT JOIN</code> = <code>LEFT OUTER JOIN</code>.</p>
<h2>GROUP BY &amp; aggregates (multiple examples)</h2>
<p><code>GROUP BY</code> collapses rows that share a value into one row per group; the aggregate describes each group.</p>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>GROUP BY team_id</code></td>
<td>make one output row per distinct <code>team_id</code></td>
</tr>
<tr>
<td><code>SELECT team_id, COUNT(*) ... GROUP BY team_id</code></td>
<td>how many rows in each team</td>
</tr>
<tr>
<td><code>SELECT team_id, SUM(amount) ... GROUP BY team_id</code></td>
<td>total <code>amount</code> per team</td>
</tr>
<tr>
<td><code>SELECT team_id, AVG(score) ... GROUP BY team_id</code></td>
<td>average <code>score</code> per team</td>
</tr>
<tr>
<td><code>SELECT team_id, MAX(created_at) ... GROUP BY team_id</code></td>
<td>the latest <code>created_at</code> per team</td>
</tr>
<tr>
<td><code>COUNT(email)</code></td>
<td>count rows where <code>email</code> isn't NULL (vs <code>COUNT(*)</code> = all rows)</td>
</tr>
<tr>
<td><code>COUNT(DISTINCT user_id)</code></td>
<td>how many <strong>different</strong> users</td>
</tr>
<tr>
<td><code>GROUP BY team_id, role</code></td>
<td>one row per (team, role) combination</td>
</tr>
<tr>
<td><code>GROUP BY team_id HAVING COUNT(*) &gt; 5</code></td>
<td>keep only the <strong>groups</strong> with more than 5 rows (filter after grouping)</td>
</tr>
<tr>
<td><code>WHERE ...</code> vs <code>HAVING ...</code></td>
<td><code>WHERE</code> filters rows <strong>before</strong> grouping; <code>HAVING</code> filters groups <strong>after</strong> aggregating</td>
</tr>
</tbody></table>
<h2>CREATE TABLE — column rules (constraints)</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>name text</code></td>
<td>a <code>name</code> column holding any string</td>
</tr>
<tr>
<td><code>age integer</code></td>
<td>a whole number</td>
</tr>
<tr>
<td><code>is_active boolean</code></td>
<td>true / false</td>
</tr>
<tr>
<td><code>id uuid</code></td>
<td>a long random identifier</td>
</tr>
<tr>
<td><code>created_at timestamptz</code></td>
<td>a moment in time (with timezone)</td>
</tr>
<tr>
<td><code>NOT NULL</code></td>
<td>this cell can never be empty</td>
</tr>
<tr>
<td><code>DEFAULT false</code></td>
<td>if no value is given, use <code>false</code></td>
</tr>
<tr>
<td><code>DEFAULT now()</code></td>
<td>if not given, stamp the current time</td>
</tr>
<tr>
<td><code>PRIMARY KEY</code></td>
<td>the row's unique name-tag — unique + not null + fast to find</td>
</tr>
<tr>
<td><code>UNIQUE</code></td>
<td>no two rows may share this value (multiple <code>NULL</code>s are still allowed)</td>
</tr>
<tr>
<td><code>UNIQUE (team_id, user_id)</code></td>
<td>no two rows may share this <strong>pair</strong> (each user only once per team)</td>
</tr>
<tr>
<td><code>CHECK (age &gt;= 0)</code></td>
<td>reject any row where this isn't true</td>
</tr>
<tr>
<td><code>team_id uuid REFERENCES teams(id)</code></td>
<td><code>team_id</code> must be a real <code>teams.id</code> — a foreign key</td>
</tr>
</tbody></table>
<h2>Foreign keys — ON DELETE (multiple readings)</h2>
<p>A foreign key lives on the row that <strong>points</strong>. Example: a <code>team_members</code> row points at a <code>teams</code> row through <code>team_id</code>. <code>ON DELETE</code> decides what happens to <strong>me, the pointing row,</strong> when the row I point to is deleted.</p>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>team_members.team_id REFERENCES teams(id)</code></td>
<td>a <code>team_members</code> row must point at a real <code>teams</code> row — never at nothing</td>
</tr>
<tr>
<td><code>... ON DELETE CASCADE</code></td>
<td>delete a team → also delete every <code>team_members</code> row that points at it</td>
</tr>
<tr>
<td>· "when the row I point to is deleted, delete me too"</td>
<td></td>
</tr>
<tr>
<td><code>teams.created_by_id REFERENCES users(id) ON DELETE SET NULL</code></td>
<td>delete the creator → keep the team, set its <code>created_by_id</code> to <code>NULL</code></td>
</tr>
<tr>
<td>· "when the row I point to is deleted, keep me — just blank my pointer"</td>
<td></td>
</tr>
<tr>
<td><code>... ON DELETE RESTRICT</code></td>
<td>refuse to delete a <code>teams</code> row while any <code>team_members</code> row still points at it</td>
</tr>
<tr>
<td>· "you can't delete what I still point to"</td>
<td></td>
</tr>
<tr>
<td><code>... ON DELETE NO ACTION</code></td>
<td>same effect as RESTRICT — block the delete (checked at the end of the statement)</td>
</tr>
</tbody></table>
<h2>Indexes</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>CREATE INDEX ON team_members (user_id)</code></td>
<td>keep a lookup shortcut so "find by <code>user_id</code>" is fast (no full-table scan)</td>
</tr>
<tr>
<td><code>CREATE UNIQUE INDEX ON team_members (team_id, user_id)</code></td>
<td>a shortcut <strong>and</strong> a rule: <code>(team_id, user_id)</code> must be unique</td>
</tr>
<tr>
<td><code>CREATE INDEX ON events (user_id, created_at)</code></td>
<td>shortcut for looking up by <code>user_id</code>, or by <code>user_id</code> <strong>then</strong> <code>created_at</code> (column order matters)</td>
</tr>
</tbody></table>
<h2>Transactions</h2>
<table>
<thead>
<tr>
<th>SQL</th>
<th>English reading</th>
</tr>
</thead>
<tbody><tr>
<td><code>BEGIN;</code></td>
<td>start a transaction — hold the next changes together</td>
</tr>
<tr>
<td><code>COMMIT;</code></td>
<td>make all of them land at once</td>
</tr>
<tr>
<td><code>ROLLBACK;</code></td>
<td>undo everything since <code>BEGIN</code> — as if none of it happened</td>
</tr>
<tr>
<td><code>BEGIN; … COMMIT;</code></td>
<td>do all of these as one indivisible unit: all, or nothing</td>
</tr>
</tbody></table>
<p>And now for the meat of the article. If you want a more in depth explanation, below is a blog post where I get into the details of it.</p>
<h1>How SQL works</h1>
<p>SQL looks like a wall of shouting keywords.</p>
<pre><code class="language-plaintext">SELECT ... FROM ... WHERE ... JOIN ... ON ... GROUP BY ... FOREIGN KEY ... CASCADE
</code></pre>
<p>The usual tutorial hands you that wall and starts defining the bricks. Which is not an explanation. It's a glossary.</p>
<p>So let me give you the one idea the whole thing rests on, and then everything above turns into plain sentences.</p>
<p>Here it is:</p>
<pre><code class="language-plaintext">A database is a pile of grids.
SQL is how you talk to the grids.
</code></pre>
<p>That's it. That is the whole mental model. A grid is a table — rows and columns, like a spreadsheet with rules. SQL is the language for making grids, putting rows in them, asking questions about them, and connecting one grid to another.</p>
<p>Every keyword below is just a word in that conversation.</p>
<p>I'll use one running example the whole way: a small app with <strong>users</strong>, <strong>teams</strong>, and the memberships that connect them. Three people — Mara, Sam, Theo. One team — <em>Design crew</em>. Watch them move through every idea.</p>
<hr />
<h2>A table is a grid</h2>
<p>Picture the <code>users</code> table as a literal grid.</p>
<pre><code class="language-plaintext">id       | email             | name  | email_verified
---------+-------------------+-------+---------------
u_mara   | mara@example.com  | Mara  | true
u_sam    | sam@example.com   | Sam   | false
u_theo   | theo@example.com  | Theo  | true
</code></pre>
<p>Columns are the headings: <code>id</code>, <code>email</code>, <code>name</code>, <code>email_verified</code>.</p>
<p>Rows are the entries: one per person.</p>
<p>A <strong>cell</strong> is where a row meets a column — Mara's email is one cell.</p>
<p>(Real ids are long random strings like <code>cc512230-de30-44c7-a197-fd6a88cb3f3c</code>. I'm writing <code>u_mara</code> so the examples stay readable. More on why ids look like that later.)</p>
<p>That's the whole shape of a database. Grids of rows. Everything else is talking to them.</p>
<hr />
<h2>Making a grid: CREATE TABLE</h2>
<p>Before a grid can hold rows, you declare its columns. That's <code>CREATE TABLE</code>.</p>
<pre><code class="language-sql">CREATE TABLE users (
  id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email          text NOT NULL,
  name           text,
  email_verified boolean NOT NULL DEFAULT false,
  created_at     timestamp with time zone NOT NULL DEFAULT now()
);
</code></pre>
<p>Read it as a sentence: "make a grid called <code>users</code>, with these columns."</p>
<p>Each line inside the parentheses is one column, and it has three parts:</p>
<pre><code class="language-plaintext">email          text          NOT NULL
^ the name     ^ the type    ^ the rules
</code></pre>
<p>The <strong>name</strong> is the heading. The <strong>type</strong> is what kind of value the cell may hold. The <strong>rules</strong> are promises the database will enforce.</p>
<p>Let me name the types you'll actually use:</p>
<ul>
<li><p><code>text</code> — a string of any length. <code>'mara@example.com'</code>.</p>
</li>
<li><p><code>boolean</code> — <code>true</code> or <code>false</code>.</p>
</li>
<li><p><code>integer</code> — a whole number. <code>42</code>.</p>
</li>
<li><p><code>timestamp with time zone</code> — a moment in time that knows its timezone.</p>
</li>
<li><p><code>uuid</code> — a long random identifier.</p>
</li>
</ul>
<p>And the rules:</p>
<ul>
<li><p><code>NOT NULL</code> — this cell can never be empty. (<code>NULL</code> is SQL's word for "no value at all." <code>NOT NULL</code> forbids it. More on <code>NULL</code> soon — it's sneakier than it looks.)</p>
</li>
<li><p><code>DEFAULT &lt;value&gt;</code> — if nobody supplies this cell, fill it with this. <code>email_verified</code> defaults to <code>false</code>; <code>created_at</code> defaults to <code>now()</code> (the current time, stamped automatically).</p>
</li>
<li><p><code>PRIMARY KEY</code> — this column is the row's unique name-tag. No two rows may share one, and it's the fast way to find a single row.</p>
</li>
</ul>
<p>One line that trips people up:</p>
<pre><code class="language-sql">id uuid PRIMARY KEY DEFAULT gen_random_uuid()
</code></pre>
<p><code>uuid</code> is the <strong>type</strong> — what the cell holds. <code>gen_random_uuid()</code> is a <strong>function</strong> — it produces a fresh random id each time it runs. So the column <em>holds</em> a uuid, and its <em>default</em> is "call this function to make one." That's why we never invent ids by hand — insert a row, and the database mints its <code>id</code>. Random uuids almost never collide, so two servers can both create rows and never clash. That's the whole reason to prefer them over a counter like <code>1, 2, 3</code>.</p>
<hr />
<h2>Putting a row in: INSERT</h2>
<p>The grid exists but it's empty. <code>INSERT</code> adds a row.</p>
<pre><code class="language-sql">INSERT INTO users (email, name) VALUES ('mara@example.com', 'Mara');
</code></pre>
<p>"Into the <code>users</code> grid, in the <code>email</code> and <code>name</code> columns, put these values."</p>
<p>Notice what I <em>didn't</em> write: no <code>id</code>, no <code>email_verified</code>, no <code>created_at</code>. I left them out on purpose, and the defaults filled them in — a fresh uuid, <code>false</code>, and the current time. The row that lands is complete:</p>
<pre><code class="language-plaintext">id       | email             | name  | email_verified | created_at
---------+-------------------+-------+----------------+---------------------
u_mara   | mara@example.com  | Mara  | false          | 2026-07-08 09:14:...
</code></pre>
<p>You can insert several rows at once:</p>
<pre><code class="language-sql">INSERT INTO users (email, name) VALUES
  ('sam@example.com',  'Sam'),
  ('theo@example.com', 'Theo');
</code></pre>
<p>Now the grid has three rows. Let's ask for them back.</p>
<hr />
<h2>Asking for it back: SELECT ... FROM ... WHERE</h2>
<p>This is the sentence you'll write more than any other. It has three parts, and each answers one question.</p>
<pre><code class="language-sql">SELECT email, name          -- which COLUMNS do I want?
FROM users                  -- from which GRID?
WHERE email_verified = true -- which ROWS?
</code></pre>
<p>Read it top to bottom:</p>
<pre><code class="language-plaintext">SELECT   →  which columns
FROM     →  which grid
WHERE    →  which rows
</code></pre>
<p><code>SELECT *</code> means "every column" (the <code>*</code> is "all"). <code>SELECT email, name</code> means just those two.</p>
<p><code>FROM users</code> picks the grid.</p>
<p><code>WHERE email_verified = true</code> is the filter. Only rows where that's true come back.</p>
<p>So that query returns Mara and Theo (verified), not Sam (not verified):</p>
<pre><code class="language-plaintext">email             | name
------------------+------
mara@example.com  | Mara
theo@example.com  | Theo
</code></pre>
<p>Change the question by changing the <code>WHERE</code>. Want just Sam?</p>
<pre><code class="language-sql">SELECT * FROM users WHERE email = 'sam@example.com';
</code></pre>
<p>The database walks the grid, keeps the rows the <code>WHERE</code> approves of, and hands back the columns the <code>SELECT</code> asked for. That loop — filter rows, pick columns — is 80% of SQL.</p>
<hr />
<h2>WHERE is where the thinking happens</h2>
<p>The <code>WHERE</code> clause is a yes/no test run against every row. The row stays if the test is true.</p>
<p>You have the comparisons you'd expect:</p>
<pre><code class="language-sql">WHERE created_at &gt; '2026-01-01'          -- after a date
WHERE name = 'Mara'                      -- exactly equal
WHERE name != 'Mara'                     -- not equal
</code></pre>
<p>And you can combine tests with <code>AND</code> and <code>OR</code>:</p>
<pre><code class="language-sql">SELECT * FROM users
WHERE email_verified = true
  AND created_at &gt; '2026-01-01';
</code></pre>
<p>"Verified <strong>and</strong> created this year." A row must pass both.</p>
<p><code>OR</code> means either is enough:</p>
<pre><code class="language-sql">WHERE name = 'Mara' OR name = 'Sam';
</code></pre>
<p>Now, the sneaky one. <code>NULL</code> — the "no value at all" from earlier — does not behave like a value.</p>
<p>Here's the trap:</p>
<pre><code class="language-sql">-- ✗ this returns NOTHING, even for rows where name really is empty
SELECT * FROM users WHERE name = NULL;
</code></pre>
<p>You'd expect it to find the rows with no name. It finds none.</p>
<p>Why? Because <code>NULL</code> means <em>unknown</em>, and "is this unknown thing equal to unknown?" isn't <code>true</code> — it's itself unknown. So the row fails the test. <code>= NULL</code> can never be true for anyone.</p>
<p>The fix is a special operator that asks the question directly:</p>
<pre><code class="language-sql">-- ✓ the right way to ask "is this cell empty?"
SELECT * FROM users WHERE name IS NULL;
</code></pre>
<p><code>IS NULL</code> and <code>IS NOT NULL</code> are how you test for emptiness. Reach for <code>=</code> and you'll silently get nothing back. This bites everyone once.</p>
<hr />
<h2>Sorting and limiting: ORDER BY, LIMIT</h2>
<p>Rows come back in no guaranteed order unless you ask for one. <code>ORDER BY</code> sorts them.</p>
<pre><code class="language-sql">SELECT name, created_at FROM users
ORDER BY created_at DESC;
</code></pre>
<p><code>DESC</code> = descending, newest first. <code>ASC</code> = ascending, oldest first (and it's the default).</p>
<p><code>LIMIT</code> caps how many rows come back:</p>
<pre><code class="language-sql">SELECT name FROM users
ORDER BY created_at DESC
LIMIT 1;
</code></pre>
<p>"The single most recently created user." Sort newest-first, then take one.</p>
<p>That pairing — <code>ORDER BY</code> then <code>LIMIT</code> — is how you get "the latest," "the top 10," "the most recent 5."</p>
<hr />
<h2>Changing a row: UPDATE (and the WHERE you must never forget)</h2>
<p><code>UPDATE</code> changes cells in rows that already exist.</p>
<pre><code class="language-sql">UPDATE users
SET email_verified = true
WHERE email = 'sam@example.com';
</code></pre>
<p>"In the <code>users</code> grid, set <code>email_verified</code> to true, <strong>for the row where</strong> email is Sam's."</p>
<p>The <code>SET</code> says what to change. The <code>WHERE</code> says <em>which rows</em> — and it is the most important word in the statement.</p>
<p>Here's the mistake that has ruined real production databases:</p>
<pre><code class="language-sql">-- ✗ NO WHERE — this verifies EVERY user in the table
UPDATE users SET email_verified = true;
</code></pre>
<p>No <code>WHERE</code> means "every row." You meant to update Sam. You just marked all three million users as verified, in one keystroke, with no undo.</p>
<p>The rule burns itself into you fast: <strong>an UPDATE without a WHERE hits everything.</strong> Write the <code>WHERE</code> first.</p>
<hr />
<h2>Removing a row: DELETE</h2>
<p><code>DELETE</code> throws rows away. Same lesson, sharper.</p>
<pre><code class="language-sql">DELETE FROM users WHERE email = 'theo@example.com';
</code></pre>
<p>"Remove the row where email is Theo's." Theo is gone.</p>
<p>And the same landmine:</p>
<pre><code class="language-sql">-- ✗ NO WHERE — this empties the entire table
DELETE FROM users;
</code></pre>
<p>No <code>WHERE</code>, no survivors. Every row, gone.</p>
<p>So for both <code>UPDATE</code> and <code>DELETE</code>, the <code>WHERE</code> is not optional decoration. It's the difference between "change one thing" and "change everything." A good habit: write the <code>WHERE</code> before you write the <code>SET</code> or the <code>DELETE</code>, so the target exists before the action does.</p>
<hr />
<h2>Rows that point at other rows: the foreign key</h2>
<p>So far, one grid at a time. But real data connects.</p>
<p>A membership connects a user to a team. So there's a third grid, <code>team_members</code>, that sits between <code>users</code> and <code>teams</code>. Each row says "this user is in this team, with this role":</p>
<pre><code class="language-sql">CREATE TABLE team_members (
  id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role    text NOT NULL DEFAULT 'member'
);
</code></pre>
<p>Look at <code>team_id</code>. On its own it's just a uuid sitting in a cell. Nothing yet forces it to match a <em>real</em> team. The <code>REFERENCES teams(id)</code> is what forces it. That is a <strong>foreign key</strong>.</p>
<p>Read it in plain English:</p>
<pre><code class="language-plaintext">the team_members.team_id column
must always REFERENCE a real teams.id
— you can't have a membership pointing at a team that doesn't exist.
</code></pre>
<p>That's the whole idea of a foreign key: a column whose value must exist as a real row in another grid. Try to insert a membership for a team id that isn't there, and the database refuses. It's the rule that keeps the grids honest with each other — no memberships floating in space, pointing at nothing.</p>
<p>A foreign key is what turns a pile of separate grids into a connected web.</p>
<hr />
<h2>When the row I point at is deleted, what happens to me?</h2>
<p>A foreign key raises a question the moment you try to delete something.</p>
<p>Say you delete the <em>Design crew</em> team. What should happen to the membership rows that point at it? They can't keep pointing at a team that's gone — that's the exact thing the foreign key forbids.</p>
<p>So every foreign key must answer one question:</p>
<pre><code class="language-plaintext">When the row I point at is deleted, what happens to me?
</code></pre>
<p>You answer it with <code>ON DELETE</code>. There are two answers you'll reach for.</p>
<p><code>ON DELETE CASCADE</code> <strong>— "delete me too."</strong></p>
<pre><code class="language-sql">team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE
</code></pre>
<p>Delete a team, and all its membership rows vanish with it. Delete a user, and all <em>their</em> membership rows vanish. No orphaned memberships pointing at a deleted team or a deleted user. Clean. The deletion <em>cascades</em> — it flows down the chain from the parent to the rows that depend on it.</p>
<p><code>ON DELETE SET NULL</code> <strong>— "don't delete me, just blank the pointer."</strong></p>
<p>Here's a different case. A team remembers who created it:</p>
<pre><code class="language-sql">CREATE TABLE teams (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name          text NOT NULL,
  created_by_id uuid REFERENCES users(id) ON DELETE SET NULL
);
</code></pre>
<p>Now delete the user who created <em>Design crew</em>.</p>
<p>You do <strong>not</strong> want the team to vanish — other people are still in it. So <code>ON DELETE SET NULL</code> says: keep the team, just set <code>created_by_id</code> to <code>NULL</code>. The pointer goes blank; the row lives on. The team outlives its creator.</p>
<p>That single word — <code>CASCADE</code> versus <code>SET NULL</code> — is the entire difference between "the child dies with the parent" and "the child survives, forgetting the parent." It's worth pausing on, because getting it wrong means either orphaned rows or accidental mass-deletion.</p>
<pre><code class="language-plaintext">parent row deleted
↓
CASCADE   → the rows pointing at it are deleted too
SET NULL  → the rows survive, their pointer set to NULL
</code></pre>
<p>(You may notice <code>created_by_id</code> has no <code>NOT NULL</code>. It can't — <code>SET NULL</code> needs to be <em>allowed</em> to write a blank there. The two rules go together.)</p>
<hr />
<h2>Two grids, one question: JOIN</h2>
<p>Now the payoff. Data lives in separate grids, but questions cross them.</p>
<p>"Which teams is Sam in?" The answer needs <code>team_members</code> (who's in what) <em>and</em> <code>teams</code> (the team's name). One grid can't answer it alone.</p>
<p>A <code>JOIN</code> stitches two grids together on a matching column.</p>
<pre><code class="language-sql">SELECT teams.name, team_members.role
FROM team_members
JOIN teams ON team_members.team_id = teams.id
WHERE team_members.user_id = 'u_sam';
</code></pre>
<p>Read the <code>JOIN ... ON</code> as: "glue each <code>team_members</code> row to the <code>teams</code> row where their ids match." The <code>ON</code> is the matching rule — <code>team_members.team_id = teams.id</code>.</p>
<p>For each membership row, the database finds the team it points at and lays the two rows side by side, into one wider row:</p>
<pre><code class="language-plaintext">teams.name    | team_members.role
--------------+------------------
Design crew   | member
</code></pre>
<p>Sam's membership pointed at <em>Design crew</em>; the join pulled in that team's <code>name</code>. Two grids, one combined answer.</p>
<p>You qualify columns with their grid — <code>teams.name</code>, <code>team_members.role</code> — because once two grids are joined, a bare <code>name</code> could be ambiguous. Say which grid you mean.</p>
<hr />
<h2>The many-to-many, and why it needs a table in the middle</h2>
<p>Why does <code>team_members</code> exist at all? Why not just... put the members on the team?</p>
<p>Because a user can be in <strong>many</strong> teams, and a team has <strong>many</strong> members. That's a <em>many-to-many</em> relationship, and neither grid can hold it alone. A <code>teams</code> row can't list an unbounded number of members in one cell. A <code>users</code> row can't list an unbounded number of teams.</p>
<p>So the relationship gets its own grid. Each row is one "this user is in this team" fact. That middle grid is a <strong>join table</strong>, and you read it both directions:</p>
<pre><code class="language-plaintext">"Which teams is Sam in?"    → team_members rows where user_id = Sam
"Who is in Design crew?"    → team_members rows where team_id = Design crew
</code></pre>
<p>One grid, both questions. This pattern — two things that relate many-to-many, joined by a table in the middle — is everywhere once you see it. Students and classes. Orders and products. Users and teams.</p>
<hr />
<h2>INNER JOIN vs LEFT JOIN — the difference is who gets dropped</h2>
<p>There's a fork in <code>JOIN</code> that matters, and the names hide it. Let me show the bug first.</p>
<p>You want a roster: every user, and their role if they're on a team.</p>
<pre><code class="language-sql">-- looks right...
SELECT users.name, team_members.role
FROM users
JOIN team_members ON team_members.user_id = users.id;
</code></pre>
<p>Run it and Theo is <strong>missing</strong>.</p>
<pre><code class="language-plaintext">name  | role
------+--------
Mara  | owner
Sam   | member
</code></pre>
<p>Theo is in no team. A plain <code>JOIN</code> — an <strong>inner</strong> join — only keeps rows where <em>both</em> sides match. Theo has no <code>team_members</code> row, so he has nothing to match, so he falls out entirely. The join silently dropped him.</p>
<p>Sometimes that's what you want. Here it isn't — you wanted <em>every</em> user.</p>
<p>The fix is <code>LEFT JOIN</code>. It keeps every row from the left grid (<code>users</code>), matched or not. Where there's no match, it fills the right side with <code>NULL</code>:</p>
<pre><code class="language-sql">SELECT users.name, team_members.role
FROM users
LEFT JOIN team_members ON team_members.user_id = users.id;
</code></pre>
<pre><code class="language-plaintext">name  | role
------+--------
Mara  | owner
Sam   | member
Theo  | NULL     ← kept, with a blank role
</code></pre>
<p>Now Theo is there, his role <code>NULL</code> because he has none.</p>
<p>That's the whole distinction:</p>
<pre><code class="language-plaintext">INNER JOIN  → keep only rows that matched on both sides
LEFT JOIN   → keep ALL left rows; NULL-fill the right where nothing matched
</code></pre>
<p>"Where did that row go?" in a report is, nine times out of ten, an inner join that should have been a left join.</p>
<hr />
<h2>The other two joins: RIGHT and FULL</h2>
<p>INNER and LEFT are the two you'll write almost every day. But there are four in total — so let me close the set, and clear up a word while I'm at it.</p>
<p><strong>"OUTER" is noise.</strong> You'll see <code>LEFT OUTER JOIN</code>, <code>RIGHT OUTER JOIN</code>, <code>FULL OUTER JOIN</code>. The <code>OUTER</code> adds nothing — <code>LEFT JOIN</code> and <code>LEFT OUTER JOIN</code> are the exact same thing. So "right join" and "outer join" aren't two separate answers; <code>OUTER</code> is just the formal middle name of joins you already have. Four joins, one word you can ignore.</p>
<p>Picture two overlapping circles — <code>users</code> on the left, <code>team_members</code> on the right. The overlap is the rows that match on the <code>ON</code> condition. Each join keeps a different region:</p>
<pre><code class="language-plaintext">INNER JOIN  → just the overlap             (only rows matched on both sides)
LEFT JOIN   → whole LEFT circle + overlap   (all users; NULL where no membership)
RIGHT JOIN  → whole RIGHT circle + overlap  (all memberships; NULL where no user)
FULL JOIN   → both circles, whole           (everything; NULL wherever either side is missing)
</code></pre>
<p><code>RIGHT JOIN</code> is the mirror of <code>LEFT</code>: keep every row from the <em>right</em> grid, NULL-fill the left. <code>FULL JOIN</code> keeps everything from <em>both</em> — a user with no membership comes back with a NULL role, and a membership with no matching user comes back with a NULL name.</p>
<p>Now the honest part, and it's the lesson worth keeping. On <em>this</em> pair of grids, RIGHT and FULL are secretly the same as joins you've already seen — and the foreign key is why.</p>
<p>Remember: <code>team_members.user_id</code> REFERENCES <code>users.id</code>. A membership <strong>can never</strong> point at a user who doesn't exist — the database forbids it. So the part of the right circle sticking out past the overlap — memberships with no user — is always empty.</p>
<p>Watch what that does. <code>RIGHT JOIN</code> keeps all memberships:</p>
<pre><code class="language-sql">SELECT users.name, team_members.role
FROM users
RIGHT JOIN team_members ON team_members.user_id = users.id;
</code></pre>
<p>But every membership already has a real user, so nothing gets NULL-filled — you get exactly the matched rows. <strong>RIGHT JOIN here collapses to INNER JOIN.</strong> Same two rows, Mara and Sam:</p>
<pre><code class="language-plaintext">name | role
-----+--------
Sam  | member
Mara | owner
</code></pre>
<p>And <code>FULL JOIN</code> here collapses to <code>LEFT JOIN</code>. The only unmatched rows that exist are users without a membership (Theo, and a nameless account), so FULL adds nothing beyond what LEFT already kept — the same four rows, Theo and the nameless one with a NULL role.</p>
<p>That's the real reason I reached for LEFT and skipped RIGHT earlier. On this schema the gap only runs one way:</p>
<pre><code class="language-plaintext">a user can have no team          → LEFT JOIN surfaces it
a membership can't have no user  → the foreign key already made that impossible
</code></pre>
<p>LEFT shows the gap that can actually happen. RIGHT would go looking for a gap the foreign key has ruled out.</p>
<p>RIGHT and FULL earn their keep when <em>both</em> sides can have unmatched rows — two independent lists you're reconciling, wanting to see what each has that the other is missing. With a foreign key in play, that's usually not your situation, so you'll live in INNER and LEFT.</p>
<p>And a practical note: almost nobody writes <code>RIGHT JOIN</code> anyway. <code>A RIGHT JOIN B</code> is just <code>B LEFT JOIN A</code> with the grids swapped, and since we read left-to-right, people flip it so the "keep them all" grid comes first. So the family quietly narrows back down to the two you started with.</p>
<p>(There's also <code>CROSS JOIN</code> — every left row paired with every right row, no <code>ON</code> at all. It builds combinations rather than matching rows, a different job entirely; you'll rarely reach for it.)</p>
<p>The one model that covers all of them:</p>
<pre><code class="language-plaintext">a JOIN matches rows across two grids;
the word in front only decides which UNMATCHED rows survive —
left, right, both, or neither.
</code></pre>
<hr />
<h2>Counting: GROUP BY</h2>
<p>One more kind of question: not "which rows" but "how many," per something.</p>
<p>"How many members does each team have?"</p>
<p>You don't want the rows themselves — you want a <em>count per team</em>. That's <code>GROUP BY</code>. It collapses rows that share a value into one group, and lets you count (or sum, or average) each group.</p>
<pre><code class="language-sql">SELECT team_id, COUNT(*) AS member_count
FROM team_members
GROUP BY team_id;
</code></pre>
<p><code>GROUP BY team_id</code> gathers all the rows with the same <code>team_id</code> into one bucket. <code>COUNT(*)</code> counts the rows in each bucket. <code>AS member_count</code> just names the output column.</p>
<pre><code class="language-plaintext">team_id       | member_count
--------------+-------------
t_design_crew | 2
</code></pre>
<p><code>COUNT(*)</code> is the common one, but the same shape works with <code>SUM(...)</code>, <code>AVG(...)</code>, <code>MAX(...)</code>. The mental model: <code>GROUP BY</code> <strong>turns many rows into one row per group, and the count/sum describes each group.</strong></p>
<hr />
<h2>Making it fast: the index</h2>
<p>Everything so far is about <em>correctness</em>. This one is about <em>speed</em>.</p>
<p>Ask "which teams is Sam in?" and, by default, the database reads <strong>every single row</strong> in <code>team_members</code> and checks each one's <code>user_id</code>. Three rows, fine. Three million rows, slow — every time.</p>
<p>An <strong>index</strong> fixes that. It's a lookup shortcut the database maintains on the side:</p>
<pre><code class="language-sql">CREATE INDEX team_members_user_idx ON team_members (user_id);
</code></pre>
<p>Now "find Sam's memberships" jumps straight to his rows instead of scanning the whole grid — the same way the index at the back of a book beats flipping every page.</p>
<p>An index costs a little: it takes space, and it must be updated on every write. So you don't index every column — you index the ones you <em>look things up by</em>.</p>
<p>There's a second kind that does double duty:</p>
<pre><code class="language-sql">CREATE UNIQUE INDEX team_members_team_user ON team_members (team_id, user_id);
</code></pre>
<p>A <code>UNIQUE</code> index is a shortcut <strong>and a rule</strong>: the pair <code>(team_id, user_id)</code> must be unique across the whole grid. Meaning the same user can't be in the same team twice. If two "add Sam to Design crew" requests arrive at the same instant, the database lets the first win and rejects the second — the uniqueness is enforced by the database, not by fragile "check, then insert" code that two requests can both slip through.</p>
<hr />
<h2>All-or-nothing: the transaction</h2>
<p>Last idea, and it's the one that separates toy SQL from real SQL.</p>
<p>Creating a team is really <em>two</em> writes: insert the team, then insert the membership that makes the creator its owner. Both have to happen, or the team is born broken — a team with no owner that nobody can manage.</p>
<p>Here's the danger, written the naive way:</p>
<pre><code class="language-sql">INSERT INTO teams (name, created_by_id) VALUES ('Design crew', 'u_mara');
-- ... what if the process crashes RIGHT HERE? ...
INSERT INTO team_members (team_id, user_id, role) VALUES ('t_design_crew', 'u_mara', 'owner');
</code></pre>
<p>If the process dies between the two lines, the first row committed and the second never ran. Now there's a team with no members. A ghost.</p>
<p>A <strong>transaction</strong> fixes this. You wrap the statements so the database treats them as one indivisible unit — <strong>all of them commit, or none of them do.</strong></p>
<pre><code class="language-sql">BEGIN;
  INSERT INTO teams (name, created_by_id) VALUES ('Design crew', 'u_mara');
  INSERT INTO team_members (team_id, user_id, role) VALUES ('t_design_crew', 'u_mara', 'owner');
COMMIT;
</code></pre>
<p><code>BEGIN</code> opens the transaction. <code>COMMIT</code> seals it — both rows land at the same instant. If anything between them fails, you <code>ROLLBACK</code> (or the database does it for you), and it's as if <em>neither</em> statement ever ran. The team can never exist without its owner.</p>
<pre><code class="language-plaintext">BEGIN
↓
insert the team
insert the owner membership
↓
COMMIT  → both land together
   or
ROLLBACK → neither happened, no ghost
</code></pre>
<p>That property — several changes that must be true <em>together</em> — is what transactions are for. Any time "do X" really means "do X and Y, and half of it would be a mess," wrap them.</p>
<hr />
<h2>What SQL is not, and when to reach for something else</h2>
<p>Two honest notes to end on.</p>
<p>SQL is not a general programming language you write loops in. It's <em>declarative</em>: you describe the rows you want, and the database figures out how to get them. You don't tell it "scan this grid, check each row" — you say <code>WHERE email_verified = true</code>, and <em>it</em> decides whether to use an index or scan. That flip — describe the result, don't script the steps — is the mental adjustment that makes SQL click.</p>
<p>And it's not always the right tool. If your data is a bag of loosely-shaped documents with no relationships — logs, a cache, a blob of JSON you always read whole — a relational database's grids and joins are overhead you don't need. SQL earns its keep exactly when your data <em>has</em> structure and <em>has</em> relationships: users who belong to teams that own documents, where a question crosses all three and the answer must stay consistent. That's the case these grids were built for.</p>
<p>The whole thing in three beats:</p>
<pre><code class="language-plaintext">A table is a grid, and SELECT ... WHERE is how you ask it questions.
A foreign key connects two grids, and ON DELETE decides what a deletion drags with it.
A transaction makes several writes land together, so your grids are never caught half-changed.
</code></pre>
]]></content:encoded></item><item><title><![CDATA[How I migrated my website from Vercel to Hetzner ]]></title><description><![CDATA[The companion post explained the why — the six seams Vercel hid, and who does each job once it's gone.
This is the log. The what.
Every command, in order, with the traps I hit and how I got out of the]]></description><link>https://featuringcode.com/how-i-migrated-my-website-from-vercel-to-hetzner</link><guid isPermaLink="true">https://featuringcode.com/how-i-migrated-my-website-from-vercel-to-hetzner</guid><category><![CDATA[Vercel]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Caddy]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Hetzner]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 04 Jul 2026 18:31:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/7b53362c-e926-40ad-9f89-cfba42e78cf9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The companion post explained the <em>why</em> — the six seams Vercel hid, and who does each job once it's gone.</p>
<p>This is the log. The <em>what</em>.</p>
<p>Every command, in order, with the traps I hit and how I got out of them.</p>
<p>Same site as before: <a href="https://produsebaby.ro/"><code>produsebaby.ro</code></a>, a Next.js content site. Same request the whole way through:</p>
<blockquote>
<p>A parent opens their phone, types <code>produsebaby.ro/carucioare/cele-mai-bune-carucioare</code>, and wants the strollers article.</p>
</blockquote>
<p>By the end, that request travels through a stack I built by hand — and a <code>git push</code> rebuilds the whole thing on its own.</p>
<p>One note on secrets: everywhere you see <code>&lt;VPS_IP&gt;</code> or <code>&lt;user&gt;</code>, that's a placeholder. The real server IP, the GitHub username, the private keys — those never go in a blog post, and they never go in git.</p>
<p>The order matters. Each phase leans on the one before. Don't skip ahead.</p>
<pre><code class="language-plaintext">0. Prepare the code (laptop)
1. Rent the server (Hetzner)
2. First deploy — build on the VPS
3. Go live — Caddy + DNS
4. Lock it down — firewall
5. Automate — GitHub Actions
6. Put Cloudflare in front
</code></pre>
<hr />
<h2>Phase 0 — Prepare the code, on the laptop</h2>
<p>Three changes, one local test. Nothing touches a server yet.</p>
<p><strong>1. Tell Next to build a self-contained bundle.</strong> In <code>next.config.ts</code>:</p>
<pre><code class="language-ts">output: 'standalone',
</code></pre>
<p>This makes <code>next build</code> emit <code>.next/standalone</code> — the app plus only the <code>node_modules</code> it actually traced. That's what the container will run.</p>
<p><strong>2. Write the</strong> <code>Dockerfile</code><strong>.</strong> Multi-stage: a heavy <code>builder</code>, a lean <code>runner</code>.</p>
<pre><code class="language-dockerfile"># ---- builder ----
FROM oven/bun:1 AS builder
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun run build

# ---- runner ----
FROM oven/bun:1 AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["bun", "server.js"]
</code></pre>
<p><strong>Trap:</strong> <code>COPY . .</code> <strong>comes <em>before</em></strong> <code>bun install</code><strong>.</strong> The obvious order is to copy <code>package.json</code> first and install, for better caching. It breaks here. My <code>package.json</code> has a <code>prepare</code> script that runs <code>panda codegen</code>, and codegen needs the config and source present. Install before the source exists → install fails. So copy everything first.</p>
<p><strong>3. Write</strong> <code>.dockerignore</code><strong>.</strong> Keep the image lean and secret-free:</p>
<pre><code class="language-plaintext">node_modules
.next
.git
.env*
*.log
explanatory-docs
public/pagefind
</code></pre>
<p><code>.git</code> and <code>.env*</code> are the important lines — no history, no secrets in the image.</p>
<p><strong>4. Test locally.</strong> This is the whole point of Docker:</p>
<pre><code class="language-bash">docker build -t produsebaby:latest .
docker run -d -p 3000:3000 --name test produsebaby:latest
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/   # 200
</code></pre>
<pre><code class="language-plaintext">If it works in a container on the laptop,
it works in the same container on the server.
</code></pre>
<p>That's the contract. Delete the local container once you've seen <code>200</code> — it was just proof.</p>
<hr />
<h2>Phase 1 — Rent the server</h2>
<p>I bought the cheapest tier that builds comfortably: <strong>2 vCPU / 4 GB RAM / ~40 GB SSD</strong>, Ubuntu LTS, a datacenter in Germany. A few euros a month, fixed.</p>
<p><strong>The SSH key.</strong> One rule, and it's the whole security model:</p>
<pre><code class="language-plaintext">private key  → stays on the laptop, never shared
public key   → goes on the server
</code></pre>
<p>Paste the public key into the provider's panel when creating the server. You get an IP back.</p>
<p><strong>Connect:</strong></p>
<pre><code class="language-bash">ssh root@&lt;VPS_IP&gt;
</code></pre>
<p>First connection asks you to trust the host fingerprint — <code>yes</code>. Then you're in, no password, because the key did the work.</p>
<p><strong>Update the machine:</strong></p>
<pre><code class="language-bash">apt update &amp;&amp; apt upgrade -y
</code></pre>
<p>If the upgrade pulls a new kernel, it says so. A new kernel only loads after a reboot:</p>
<pre><code class="language-bash">reboot
</code></pre>
<p>The SSH session drops (normal). Wait ~30s, reconnect, confirm the new kernel:</p>
<pre><code class="language-bash">uname -r
</code></pre>
<p><strong>Install Docker.</strong> On a brand-new Ubuntu, the distro packages are the safe bet:</p>
<pre><code class="language-bash">apt install -y docker.io docker-compose-v2
docker run hello-world   # "Hello from Docker!" → engine works
</code></pre>
<p><code>docker.io</code> is the engine + <code>docker</code> CLI. <code>docker-compose-v2</code> is the <code>docker compose</code> plugin. That's everything the server needs.</p>
<hr />
<h2>Phase 2 — First deploy: build on the VPS</h2>
<p>The plan for the first deploy: get the code onto the server, build the image <em>there</em>, run it, curl it. Prove the container serves before adding HTTPS or a domain.</p>
<p><strong>1. Add swap.</strong> A 4 GB box can run out of memory mid-build and get the process <code>Killed</code>. A swap file is the safety net:</p>
<pre><code class="language-bash">fallocate -l 2G /swapfile &amp;&amp; chmod 600 /swapfile &amp;&amp; mkswap /swapfile &amp;&amp; swapon /swapfile
</code></pre>
<p><strong>2. Sync the code up</strong> with <code>rsync</code> — copy only what changed, delete what's gone, skip the junk:</p>
<pre><code class="language-bash">rsync -avz --delete \
  --exclude node_modules --exclude .next --exclude .git \
  --exclude '.env*' \
  ./ root@&lt;VPS_IP&gt;:/srv/produsebaby/
</code></pre>
<p>The excludes matter: <code>node_modules</code> and <code>.next</code> get rebuilt in the image, <code>.git</code> and <code>.env*</code> must never leave the laptop.</p>
<p><strong>3. Build, run, test</strong> — on the server:</p>
<pre><code class="language-bash">cd /srv/produsebaby
docker build -t produsebaby:latest .
docker run -d -p 3000:3000 --name produsebaby produsebaby:latest
curl -s -o /dev/null -w 'status: %{http_code}\n' http://localhost:3000/   # status: 200
</code></pre>
<p><code>status: 200</code> means the site runs on the VPS — but only on <code>localhost:3000</code>. No HTTPS, no domain yet. That's the next phase.</p>
<hr />
<h2>Phase 3 — Go live: Caddy + DNS</h2>
<p>Two containers, one config file each, then flip the domain.</p>
<pre><code class="language-plaintext">Internet (443, HTTPS)
↓
caddy    ← the only thing exposed to the internet
↓  app:3000 (private Docker network)
app      ← Next, hidden behind Caddy
</code></pre>
<p><strong>1.</strong> <code>docker-compose.yml</code> describes both containers:</p>
<pre><code class="language-yaml">services:
  app:
    build: .
    restart: unless-stopped
    volumes:
      - nextjs_cache:/app/.next/cache   # ISR + optimized images survive redeploys
    expose:
      - "3000"                          # visible ONLY to other containers

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"                       # the only ports open to the internet
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data                # issued TLS certs live here
      - caddy_config:/config
    depends_on:
      - app

volumes:
  nextjs_cache:
  caddy_data:
  caddy_config:
</code></pre>
<p><code>expose</code> vs <code>ports</code> is the security line: <code>app</code> is only reachable inside the private network; only <code>caddy</code> faces the internet.</p>
<p><strong>2.</strong> <code>Caddyfile</code> — four lines gets you automatic HTTPS:</p>
<pre><code class="language-plaintext">produsebaby.ro {
	encode gzip zstd
	reverse_proxy app:3000
}

www.produsebaby.ro {
	redir https://produsebaby.ro{uri} permanent
}
</code></pre>
<p>Naming the domain is what makes Caddy request and auto-renew a Let's Encrypt certificate for it. You configure nothing else for HTTPS.</p>
<p><strong>3. Bring the stack up:</strong></p>
<pre><code class="language-bash">docker rm -f produsebaby        # remove the manual test container
docker compose up -d --build
docker compose ps               # app + caddy both "Up"
</code></pre>
<p>Caddy starts but can't get a certificate yet — the domain still points at the old host. It retries in the background. Fine.</p>
<p><strong>4. Flip DNS.</strong> In the DNS panel, the two records that carry web traffic move to the VPS:</p>
<pre><code class="language-plaintext">produsebaby.ro   A → &lt;VPS_IP&gt;   (grey cloud / DNS only)
www              A → &lt;VPS_IP&gt;   (grey cloud / DNS only)
</code></pre>
<p><strong>Trap: you can't change a record's <em>type</em>.</strong> Mine were <code>CNAME</code>s pointing at the old host. The edit form won't let you switch <code>CNAME → A</code>. Delete each one, add a fresh <code>A</code> record.</p>
<p><strong>Trap: leave it GREY (DNS only) for now.</strong> Let's Encrypt validates over port 80. A proxy in front can interfere with that first handshake. Grey now, colored later (Phase 6).</p>
<p>Also: <strong>do not touch the MX and TXT records.</strong> Those run email. Only the two web records change.</p>
<p><strong>5. Force the certificate, verify:</strong></p>
<pre><code class="language-bash">docker compose restart caddy         # try the cert now that DNS points here
curl -sI https://produsebaby.ro/      # HTTP/2 200
</code></pre>
<p>The response headers tell the whole story:</p>
<pre><code class="language-plaintext">via: 1.1 Caddy        → traffic goes through my reverse proxy
x-nextjs-cache: HIT   → ISR is serving from cache
</code></pre>
<p>The chain works: <code>DNS → VPS → Caddy (HTTPS) → app:3000 → Next</code>. The site is live on my own server.</p>
<hr />
<h2>Phase 4 — Lock it down: the firewall</h2>
<p><strong>Trap: don't reach for</strong> <code>ufw</code><strong>.</strong> Docker writes its own iptables rules and <em>bypasses</em> <code>ufw</code> for published ports. You'd think 80/443 are filtered; Docker quietly leaves them open behind <code>ufw</code>'s back.</p>
<p>Use the provider's <strong>network-level firewall</strong> instead. It sits in front of the machine, so Docker can't route around it.</p>
<pre><code class="language-plaintext">Internet
↓
Cloud firewall   ← filters here, before the VM
↓  (only 22 / 80 / 443 pass)
VM (Docker, Caddy, app)
</code></pre>
<p>Rules — inbound only, everything else dropped:</p>
<pre><code class="language-plaintext">22/TCP    SSH    source: anywhere
80/TCP    HTTP   source: anywhere
443/TCP   HTTPS  source: anywhere
ICMP      ping   source: anywhere
outbound: allow all
</code></pre>
<p><strong>Trap: include port 22 before you apply, or you lock yourself out.</strong> And after applying, test SSH in a <em>new</em> terminal while the old one is still open — so you can undo if you got it wrong.</p>
<p>Verify from the outside — allowed ports answer instantly, blocked ones hang until timeout:</p>
<pre><code class="language-bash">for p in 22 80 443 3000 8080; do nc -z -G 5 -w 5 &lt;VPS_IP&gt; $p &amp;&amp; echo "$p open" || echo "$p blocked"; done
</code></pre>
<pre><code class="language-plaintext">22 open   80 open   443 open        ← the doors you want
3000 blocked   8080 blocked         ← ~5s each: dropped, not refused
</code></pre>
<p>That delay is the point. A <strong>refused</strong> port replies instantly with "no." A <strong>dropped</strong> port stays silent until the client gives up — the machine is invisible to a scanner on that port.</p>
<hr />
<h2>Phase 5 — Automate: GitHub Actions</h2>
<p>Until now every deploy was manual: rsync, build, restart. This phase turns <code>git push</code> into the whole thing.</p>
<pre><code class="language-plaintext">git push
↓
GitHub runner: build image → push to registry (tagged with the commit)
↓
runner SSHes into the VPS → pull that image → restart
↓
the runner disappears
</code></pre>
<p>The heavy build runs on GitHub's machine, not the little VPS. The VPS just pulls a finished image.</p>
<p><strong>1. Point compose at the registry image.</strong> Change the <code>app</code> service so it can pull a tagged image, but keep <code>build: .</code> as a manual fallback:</p>
<pre><code class="language-yaml">  app:
    image: ghcr.io/&lt;user&gt;/produsebaby:${IMAGE_TAG:-latest}
    build: .
    restart: unless-stopped
    volumes:
      - nextjs_cache:/app/.next/cache
    expose:
      - "3000"
</code></pre>
<p><strong>2. Write</strong> <code>.github/workflows/deploy.yml</code><strong>:</strong></p>
<pre><code class="language-yaml">name: Deploy
on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read
  packages: write

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    env:
      IMAGE: ghcr.io/${{ github.repository }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE }}:${{ github.sha }}
            ${{ env.IMAGE }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - uses: appleboy/ssh-action@v1
        env:
          GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          host: ${{ secrets.VPS_HOST }}
          username: root
          key: ${{ secrets.VPS_SSH_KEY }}
          envs: GHCR_TOKEN
          script: |
            echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            cd /srv/produsebaby
            export IMAGE_TAG=${{ github.sha }}
            docker compose pull
            docker compose up -d
            docker image prune -f
</code></pre>
<p>Three production habits are hiding in there: tag with <code>github.sha</code> (traceable, rollback-able — never rely on <code>:latest</code>), <code>type=gha</code> layer cache, and <code>cancel-in-progress</code> so a second push kills the first deploy.</p>
<p><strong>Trap:</strong> <code>permissions: packages: write</code><strong>.</strong> Without it, <code>GITHUB_TOKEN</code> is read-only and the push to the registry 403s. This one line is the most common first-run failure.</p>
<p><strong>Trap: pulling a <em>private</em> image on the VPS.</strong> The image is private by default. The deploy step logs in on the VPS using <code>GITHUB_TOKEN</code> — but you must forward it with <code>envs:</code> (not just <code>env:</code>), and log in with <code>--password-stdin</code>.</p>
<p><strong>3. Make a dedicated deploy key</strong> — not your personal key, so it's revocable on its own:</p>
<pre><code class="language-bash">ssh-keygen -t ed25519 -f ~/.ssh/produsebaby_deploy -N "" -C "deploy"
ssh-copy-id -f -i ~/.ssh/produsebaby_deploy.pub root@&lt;VPS_IP&gt;
</code></pre>
<p><strong>Trap:</strong> <code>ssh-copy-id</code> <strong>without</strong> <code>-f</code><strong>.</strong> It first tries to log in "to filter out keys already installed." Since you <em>already</em> have SSH access via your personal key, that login succeeds, and it wrongly concludes the new key is already there — "All keys were skipped." <code>-f</code> skips the check and installs it.</p>
<p><strong>4. Put the two secrets in GitHub</strong> (Settings → Secrets and variables → Actions):</p>
<pre><code class="language-plaintext">VPS_HOST      → &lt;VPS_IP&gt;
VPS_SSH_KEY   → the deploy PRIVATE key (~/.ssh/produsebaby_deploy)
</code></pre>
<p>For the multi-line private key, copy it exactly with the clipboard:</p>
<pre><code class="language-bash">pbcopy &lt; ~/.ssh/produsebaby_deploy
</code></pre>
<p><strong>Trap: the clipboard clobber.</strong> <code>pbcopy</code> puts the key on the clipboard — but <em>any</em> <code>Cmd+C</code> afterwards overwrites it, including copying a verification command out of a chat window. So <code>pbcopy</code> must be the <strong>last</strong> thing that touches the clipboard before you paste into GitHub. After it, go straight to the browser and paste. Nothing in between.</p>
<p><strong>Trap: know which machine you're on.</strong> Read the prompt. <code>you@laptop %</code> is local; <code>root@server #</code> is the VPS. <code>pbcopy</code> is a macOS command and the private key only exists on the laptop — it cannot run on the server. The private key never leaves your machine; only its public half is on the VPS.</p>
<p><strong>5. Sync the updated compose to the VPS.</strong> The deploy step runs <code>docker compose pull</code> on the server, so the server's compose file must have the new <code>image:</code> line:</p>
<pre><code class="language-bash">scp docker-compose.yml root@&lt;VPS_IP&gt;:/srv/produsebaby/docker-compose.yml
</code></pre>
<p><strong>Trap: a stale compose on the server is a silent no-op.</strong> If the server still has the old <code>build:</code>-only compose, <code>pull</code> has nothing to pull and the deploy just restarts the old container — "green," but nothing changed. (My earlier <code>rsync</code> had run <em>before</em> I edited the compose, so the server had the old one. This <code>scp</code> fixes it.)</p>
<p><strong>6. Push, and watch:</strong></p>
<pre><code class="language-bash">git push
</code></pre>
<p>Open the repo's <strong>Actions</strong> tab. The steps run green: checkout, login, build-push (the slow one, first run has no cache), then the SSH deploy. Confirm the server is running the exact commit:</p>
<pre><code class="language-bash">ssh root@&lt;VPS_IP&gt; 'cd /srv/produsebaby &amp;&amp; docker compose images app'
# ghcr.io/&lt;user&gt;/produsebaby   &lt;the pushed commit sha&gt;
</code></pre>
<p>From now on: <code>git push</code> is the deploy. Rollback is <code>IMAGE_TAG=&lt;older-sha&gt; docker compose up -d</code>, because every image is tagged with its commit.</p>
<hr />
<h2>Phase 6 — Put Cloudflare in front</h2>
<p>The last piece: a CDN and DDoS shield in front of the origin. Two settings, in this order.</p>
<p><strong>Order matters.</strong> Set the SSL mode <em>before</em> turning the proxy on.</p>
<p><strong>1. SSL/TLS mode → Full (strict).</strong> This makes Cloudflare talk to the origin over HTTPS <em>and</em> validate the origin's certificate. Caddy's Let's Encrypt cert is valid and trusted, so it passes immediately.</p>
<pre><code class="language-plaintext">Flexible  → Cloudflare↔origin is plain HTTP. With Caddy's HTTP→HTTPS redirect,
            this loops forever ("too many redirects"). Never use it here.
Full (strict) → both legs encrypted, origin cert validated. Correct.
</code></pre>
<p><strong>2. Turn the two A records ORANGE</strong> (Proxied). MX/TXT stay grey.</p>
<p>Now the request path changes:</p>
<pre><code class="language-plaintext">phone → DNS → Cloudflare's edge → VPS → Caddy → app
</code></pre>
<p>Verify from the outside:</p>
<pre><code class="language-bash">dig +short produsebaby.ro                                   # Cloudflare IPs, not &lt;VPS_IP&gt;
curl -sI https://produsebaby.ro/ | grep -i server           # server: cloudflare
curl -sI --resolve produsebaby.ro:443:&lt;VPS_IP&gt; https://produsebaby.ro/ | grep -i via  # via: 1.1 Caddy
</code></pre>
<pre><code class="language-plaintext">DNS now returns Cloudflare's IPs, not the server's.
Public traffic shows server: cloudflare.
The origin, hit directly, still answers via Caddy — Full strict validated its cert.
</code></pre>
<p><strong>The renewal fear was wrong.</strong> I worried the proxy would break Caddy's cert renewal (the TLS-based challenge does die behind a proxy). But Caddy falls back to the HTTP challenge, and Cloudflare lets <code>/.well-known/acme-challenge/</code> pass through to the origin. Renewal keeps working.</p>
<p><strong>What orange does <em>not</em> do: hide the origin.</strong> <code>&lt;VPS_IP&gt;</code> can still be hit directly, bypassing Cloudflare — I just did it with <code>--resolve</code>. To force <em>all</em> traffic through Cloudflare, restrict the firewall's 80/443 to Cloudflare's IP ranges only. That's real hardening, but it couples you to keeping the proxy on. Optional.</p>
<hr />
<h2>What I ended up with</h2>
<p>Follow the request now — every hop is a piece I placed by hand:</p>
<pre><code class="language-plaintext">the parent's phone
↓
DNS → Cloudflare's edge (CDN, DDoS, caches images)
↓
my VPS, port 443  (firewall: only 22/80/443 get in)
↓
caddy   → verifies TLS, decrypts, reverse_proxy app:3000
↓
app     → Next; ISR serves from cache or regenerates; sharp optimizes photos once
↓
volumes remember cache + certs across deploys
↓
the response goes back up → Caddy → Cloudflare (caches) → the phone
</code></pre>
<p>And the whole thing rebuilds itself:</p>
<pre><code class="language-plaintext">git push → GitHub builds the image → pushes it tagged with the commit
         → SSHes into the VPS → pulls that exact image → restarts
</code></pre>
<p>The five files that made it happen:</p>
<pre><code class="language-plaintext">next.config.ts              → output: 'standalone'
Dockerfile                  → the image recipe
.dockerignore               → what stays out of the image
docker-compose.yml          → app + caddy + volumes
Caddyfile                   → domain + auto HTTPS + proxy
.github/workflows/deploy.yml → git push = deploy
</code></pre>
<p><strong>What it costs to be your own platform.</strong> Vercel is on call so you're not. Here, the admin is you: you watch the cert renew (Caddy does it, you verify), you keep the machine patched, you own the backups. Not hard. Yours.</p>
<p><strong>The trade, said plainly:</strong></p>
<pre><code class="language-plaintext">Vercel glued six services into one button.
I pulled them apart and rebuilt each on a bare computer — build, hosting, HTTPS, CDN, images, ISR.
I gave up the convenience and took the control, the fixed cost, and the understanding.
</code></pre>
<p>Do the manual deploy first. Understand every hop. <em>Then</em> automate it — because now you know exactly what the automation is doing.</p>
]]></content:encoded></item><item><title><![CDATA[DevOps from scratch]]></title><description><![CDATA[Moving a site off Vercel onto a VPS and Docker
You press "deploy" on Vercel and the site works.
Domain, HTTPS, fast images, pages that update themselves.
Everything "just works."
That is the trap. It ]]></description><link>https://featuringcode.com/devops-from-scratch</link><guid isPermaLink="true">https://featuringcode.com/devops-from-scratch</guid><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 04 Jul 2026 18:00:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/a391d156-c318-47a0-9692-63ee4277ac05.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Moving a site off Vercel onto a VPS and Docker</h1>
<p>You press "deploy" on Vercel and the <a href="http://produsebaby.ro">site</a> works.</p>
<p>Domain, HTTPS, fast images, pages that update themselves.</p>
<p>Everything "just works."</p>
<p>That is the trap. It looks like Vercel is <em>one</em> thing.</p>
<p>It is not.</p>
<p>Vercel is about six different services, packaged so well that you never felt the seam between them. When you move to your own server, the seams become yours. You have to rebuild them, one at a time.</p>
<p>This post is the rebuild. It teaches DevOps the way I actually learned it — not as a list of definitions, but as one concrete job: taking a real Next.js content site, <a href="http://produsebaby.ro"><code>produsebaby.ro</code></a>, off Vercel and onto a rented Linux box running Docker.</p>
<p>And it carries one example the whole way through.</p>
<blockquote>
<p>A parent in Romania opens their phone.</p>
<p>They type <code>produsebaby.ro/carucioare/cele-mai-bune-carucioare</code>.</p>
<p>They want to read your article about the best baby strollers.</p>
</blockquote>
<p>That page exists right now in the code: <code>src/app/[category]/[slug]/page.tsx</code>, with <code>revalidate = 86400</code>. It has text. It has stroller photos. It has affiliate links.</p>
<p>Every time I say "the request," that is the request.</p>
<p>You follow it with your finger. From the parent's phone all the way to the pixels on the screen. Through every single piece.</p>
<p>By the end you should be able to close this page and say the whole flow out loud. If you can, it worked. If you only learned some new vocabulary, read it again.</p>
<hr />
<h2>Chapter 1 — What Vercel was actually doing for you</h2>
<p>Let's follow that request today, while you are still on Vercel.</p>
<p>Here is its path:</p>
<pre><code class="language-plaintext">The parent's phone
↓
DNS: "where is produsebaby.ro?" → Cloudflare → Vercel
↓
Vercel's network receives the request (nearest city to the user)
↓
HTTPS: Vercel already installed the certificate, the connection is encrypted
↓
Vercel looks for the page in its cache (ISR)
↓
Page is fresh → send it directly
Page is stale → regenerate it, then send it
↓
The images on the page → optimized and served from Vercel's CDN
↓
The parent sees the article
</code></pre>
<p>Six things happened there. Each one done by Vercel, free for your attention. Name them, because each one becomes a chapter — and each one is really the same question in disguise: <em>once Vercel is gone, who does this work now?</em></p>
<p><strong>1. It ran the build.</strong> When you did <code>git push</code>, Vercel ran the exact command from your <code>package.json</code>:</p>
<pre><code class="language-plaintext">panda codegen &amp;&amp; next build &amp;&amp; pagefind --site .next/server/app --output-path public/pagefind
</code></pre>
<p>On a VPS, the build becomes your job.</p>
<p><strong>2. It hosted the app.</strong> The built code has to run <em>somewhere</em> — a live <code>next start</code> process, waiting for requests. Vercel started it and kept it alive. On a VPS, you start the process and you keep it alive.</p>
<p><strong>3. It put HTTPS on.</strong> The padlock in the address bar. The TLS certificate, auto-renewed. You never touched this subject. Vercel made it invisible. On a VPS, you get the certificate — and the good news is this can be nearly invisible too.</p>
<p><strong>4. It served static files from a CDN.</strong> The 597MB in <code>public/</code> — 2844 images plus the Pagefind search index — were never served by your app. Vercel put them on its CDN: copies of the files in cities all over the world, close to users. That is why this odd hack exists in <code>next.config.ts</code>:</p>
<pre><code class="language-ts">outputFileTracingExcludes: {
  '*': ['public/**'],
}
</code></pre>
<p>It tells Vercel: "do not stuff the 600MB of images into the function that runs the code — the images are served separately, by the CDN." Without it, the function blew past Vercel's 300MB limit and failed. On a VPS there is no 300MB limit, so the hack becomes irrelevant. But a new, very practical question appears: <strong>who serves those 597MB now?</strong></p>
<p><strong>5. It optimized the images.</strong> When the page asks for a stroller photo, you do not get the original 2MB file. <code>next/image</code> delivers a resized WebP, exactly as wide as the phone screen — maybe 40KB instead of 2MB. That optimization ran on Vercel's servers. On a VPS, it runs <em>inside your own Node process</em>, with a library called <code>sharp</code> (already installed).</p>
<p><strong>6. It regenerated the pages by itself (ISR).</strong> <code>revalidate = 86400</code> means "this page is good for 24 hours; after that, regenerate it from fresh content." Vercel kept track of which page was stale, when to remake it, where to put it. This is the piece you are least sure about, and rightly so — it is the subtlest. The big relief: <strong>ISR is not Vercel magic. It runs on your server too.</strong></p>
<p>So what is a VPS, really? Now you can see it clearly.</p>
<pre><code class="language-plaintext">Vercel gives you:  build + hosting + HTTPS + CDN + image optimization + ISR
                   (all already running)

A VPS gives you:   a bare computer
                   (you add the rest)
</code></pre>
<p>It is not a loss. It is a trade. You lose convenience. You gain control, and usually a small fixed monthly cost instead of a bill that grows with traffic.</p>
<p>And the pieces are not many. There are exactly six, and you already have their names:</p>
<pre><code class="language-plaintext">build              → Docker + GitHub Actions
hosting            → VPS + container + compose
HTTPS              → Caddy (reverse proxy)
CDN + static files → Caddy + Cloudflare
image optimization → sharp, in your container
ISR                → cache on disk, in a volume
</code></pre>
<p>One honest thing so you don't worry needlessly: DNS is already solved. <code>produsebaby.ro</code> already lives on Cloudflare, and Cloudflare already gives you a free CDN in front. So of the six pieces, one is half-done before you start. You are not starting from zero. You are starting from Cloudflare already standing up.</p>
<hr />
<h2>Chapter 2 — The empty computer: your VPS</h2>
<p>VPS stands for Virtual Private Server. Three words that sound important and explain nothing.</p>
<p>Here is what it is, no detours:</p>
<blockquote>
<p>A VPS is a computer running in a data center, which you rent by the month, and talk to over the internet instead of with a keyboard.</p>
</blockquote>
<p>That's it.</p>
<p>No monitor. No keyboard. It sits in a room full of other computers, somewhere in Germany or Finland, powered on around the clock. You speak to it over the internet.</p>
<p><strong>Why "virtual."</strong> You <em>could</em> rent a whole physical machine. It is expensive and far too much for one site. Instead, the provider takes one big physical server and slices it in software:</p>
<pre><code class="language-plaintext">One huge physical server
↓
sliced in software into 20 smaller computers
↓
each slice = one VPS
↓
you rent one slice
</code></pre>
<p>Each slice behaves like a complete, separate computer. Its own sliver of CPU, its own memory, its own disk. Neighbors on the same physical box cannot see your files. That is why it is "virtual" (cut in software, not a physical box) and "private" (your slice is yours).</p>
<p><strong>"You have root."</strong> You will hear "you have root access" and "full control." <code>root</code> is the boss user on Linux. It can do anything: install programs, start services, delete everything by accident. On Vercel you never had this — you ran inside their box, by their rules.</p>
<pre><code class="language-plaintext">Good:  you can install Docker, run anything, nobody says no.
Bad:   if you break something, you fix it. Nobody is on call.
</code></pre>
<p>The reassuring part: with Docker (the next chapters), you touch "root" very rarely. You install Docker once, and after that you work almost entirely through it.</p>
<p><strong>How you talk to it: SSH.</strong> No monitor, so how do you give it commands? Through SSH — Secure Shell. An encrypted tunnel from the terminal on your laptop to the command line on the server.</p>
<pre><code class="language-plaintext">Your laptop (terminal)
↓  ssh, encrypted
The internet
↓
The VPS, its command line
</code></pre>
<p>It looks like this:</p>
<pre><code class="language-bash">ssh root@188.34.xxx.xxx
</code></pre>
<p><code>root</code> is who you log in as. <code>188.34.xxx.xxx</code> is the server's IP address (the provider gives it to you when you create the VPS). You press Enter, and suddenly your terminal is typing commands <em>on the server in Germany</em>.</p>
<p>You do not log in with a password. You log in with an <strong>SSH key</strong>: a pair of files, one secret on your laptop, one public on the server. They fit together like a key in a lock. Remember this shape — <em>something secret with you, its public pair on the server</em>. It comes back at deploy time.</p>
<p><strong>How big a VPS do you need?</strong> Three numbers matter.</p>
<ul>
<li><p><strong>CPU.</strong> How many "vCPU." For you it matters for two things: building the site and optimizing images with <code>sharp</code>. Both eat CPU in short bursts.</p>
</li>
<li><p><strong>RAM.</strong> How much it holds in mind at once. <code>next build</code> on a content-heavy site wants memory. Below a threshold, the build dies with "out of memory."</p>
</li>
<li><p><strong>Disk.</strong> Where the images live. You have 597MB in <code>public/</code>, plus the system, plus the Docker images. It adds up.</p>
</li>
</ul>
<p>Translated for this site — static-first, ISR, no database, small-to-medium traffic — the hard part is not serving visitors. It is building and optimizing images. A reasonable starting target:</p>
<pre><code class="language-plaintext">2 vCPU
4 GB RAM   (so the build doesn't die on your rich content)
~40–80 GB disk (images + Docker need room to breathe)
</code></pre>
<p>Do not take the smallest plan "to save money." A build that dies for lack of RAM costs you more hours than the few euros a month. And you can always grow — most providers let you resize from a panel in minutes.</p>
<p>Common providers for small VPSes: <strong>Hetzner</strong> (cheap, data centers in Germany/Finland — close to Romanian users, so low latency), <strong>DigitalOcean</strong> (friendly panel, lots of beginner docs), <strong>Vultr</strong>, <strong>Linode/Akamai</strong>, <strong>Contabo</strong> (a lot for a little, but more variable). The order of magnitude for the specs above is "a few euros up to ~15–20 €/month" — a <em>fixed</em> cost, unlike a Vercel bill that grows with traffic.</p>
<p>Here is the mistake everyone makes at the start: "VPS means I have to learn Linux administration." Partly true, but less than you think. <em>Without</em> Docker, yes — you install Node by hand, configure services, fight versions. Painful. <em>With</em> Docker, the VPS becomes almost empty inside. You install Docker once. After that, everything that runs — the app, the proxy — comes packaged. The server barely knows what Next.js is. It just runs containers.</p>
<p>And when is a VPS <em>not</em> the answer? If you want zero hassle and don't mind a cost that grows with traffic, Vercel is hard to beat — you pay precisely so you never do any of this. A VPS makes sense when you want a fixed, predictable cost, when you need control the platform won't give you, or when your reason is <em>I want to learn DevOps</em>. That last one is a very good reason, and it is the one driving this whole post.</p>
<hr />
<h2>Chapter 3 — Packaging the app: images and containers</h2>
<p>A Docker <strong>image</strong> is a sealed box with everything your app needs to run: the built code, <code>node_modules</code>, the runtime version, even the system files.</p>
<p>It solves one problem, but a big one: "works on my machine."</p>
<pre><code class="language-plaintext">Without Docker:  your laptop has version 1.3.5, the server has another → it breaks.
With Docker:     YOU package the exact versions → it runs the same everywhere.
</code></pre>
<p>An image is a <em>cooked meal</em>, sealed. Not instructions — the food, already made. You reheat it anywhere and it is identical.</p>
<p>Do not confuse the image with the container:</p>
<pre><code class="language-plaintext">The image     = the template, frozen, on disk. Does not run.
The container = an instance started from the image. Runs.
</code></pre>
<p>The image is the class, the container is the object. One image → any number of containers.</p>
<p><strong>How an image is built: the Dockerfile.</strong> An image is born from a file called <code>Dockerfile</code>: a list of steps, top to bottom. Each step is a <strong>layer</strong>, which Docker caches.</p>
<pre><code class="language-plaintext">FROM ...     ← start from a ready-made image (e.g. bun installed)
COPY ...     ← bring your files in
RUN ...      ← run commands (install, build)
CMD ...      ← the command that starts when it becomes a container
</code></pre>
<p>Layer caching is why order matters: if <code>package.json</code> hasn't changed, Docker reuses the <code>bun install</code> layer and skips it. That is why you copy <code>package.json</code> first, then the rest of the code.</p>
<p><strong>The one config change:</strong> <code>output: 'standalone'</code><strong>.</strong> A normal <code>next build</code> leaves a <code>.next</code> that needs all of <code>node_modules</code> to run — hundreds of useless MB in the image. <code>output: 'standalone'</code> tells Next: "figure out exactly which files from <code>node_modules</code> are actually used at runtime, and put them in a small folder, <code>.next/standalone</code>, with its own <code>server.js</code>." So you add to <code>next.config.ts</code>:</p>
<pre><code class="language-ts">const nextConfig: NextConfig = {
  output: 'standalone',   // ← ADD this
  images: {
    minimumCacheTTL: 2678400,
    remotePatterns: [{ protocol: 'https', hostname: '**.akamaized.net' }],
  },
  // outputFileTracingExcludes: no longer needed on a VPS (it was for Vercel's 300MB limit).
}
</code></pre>
<p>Now the trap everyone falls into:</p>
<blockquote>
<p><strong>standalone does NOT copy</strong> <code>public/</code> <strong>or</strong> <code>.next/static</code><strong>.</strong> It leaves them out on purpose. If you don't copy them into the Dockerfile yourself, ALL your images and CSS return 404.</p>
</blockquote>
<p>Hold that thought. We fix it below.</p>
<p><strong>The real Dockerfile.</strong> Your build has three steps: <code>panda codegen</code>, <code>next build</code>, then <code>pagefind</code>. The Dockerfile does all three, in two stages (multi-stage):</p>
<pre><code class="language-plaintext">Stage 1 (builder):  installs everything, builds, generates Pagefind.  Heavy.
Stage 2 (runner):   copies ONLY the result. Light. This is what ships.
</code></pre>
<pre><code class="language-dockerfile"># ---- Stage 1: builder — has everything needed to build ----
FROM oven/bun:1 AS builder
WORKDIR /app

# Manifests first → cache bun install as long as they don't change
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Then the rest of the code
COPY . .

# Your full build: panda + next build + pagefind (exactly package.json)
RUN bun run build

# ---- Stage 2: runner — only what's needed to RUN ----
FROM oven/bun:1 AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

# 1) the standalone server (small, node_modules already triaged)
COPY --from=builder /app/.next/standalone ./
# 2) the static CSS/JS — standalone omits it, you copy it (the trap)
COPY --from=builder /app/.next/static ./.next/static
# 3) the files in public/ (images + the Pagefind index built at build time)
COPY --from=builder /app/public ./public

EXPOSE 3000
CMD ["bun", "server.js"]
</code></pre>
<p>Those three <code>COPY --from=builder</code> lines are the heart. The first brings the server; the second and third fix exactly the 404 trap.</p>
<p>And a <code>.dockerignore</code> so <code>COPY . .</code> doesn't drag in your local <code>node_modules</code>, <code>.next</code>, <code>.git</code>:</p>
<pre><code class="language-plaintext">node_modules
.next
.git
.env*
*.log
</code></pre>
<p>You build the image from the project root:</p>
<pre><code class="language-bash">docker build -t produsebaby:latest .
</code></pre>
<p><code>-t produsebaby:latest</code> is the name and tag. The <code>.</code> means "the Dockerfile is here." At the end you have an image on disk, ready to start. (<code>:latest</code> is fine here, local, to learn. In automated deploys we'll tag with the commit instead — traceable and easy to roll back.)</p>
<p><strong>Now start it.</strong> The image sits on disk and does nothing. A <strong>container</strong> is that image <em>started</em> — a live process.</p>
<pre><code class="language-plaintext">docker run produsebaby:latest
↓
Docker takes the image (frozen)
↓
starts it → now it's a container running `bun server.js`
↓
your app listens on port 3000, inside
</code></pre>
<p>Here is the rule you have to feel in your stomach:</p>
<blockquote>
<p>Whatever a container writes to its own disk <strong>disappears</strong> when the container is deleted or rebuilt.</p>
</blockquote>
<p>A container is disposable. Stop it, start another from the same image, and it is clean, like new. It remembers nothing. Why that matters directly:</p>
<pre><code class="language-plaintext">ISR writes regenerated pages to disk (.next/cache).
next/image writes optimized images to disk (.next/cache/images).

Next deploy → new container → its disk is empty → all that cache is lost.
</code></pre>
<p>The fix has a name: a <strong>volume</strong>. A volume is a folder on the VPS (which stays) linked into a folder inside the container. What the container writes there lives on the VPS, not in the container. New container → same volume → the cache is still there. Remember the shape: <em>the container is disposable; the volume is the memory that survives.</em> We use it seriously in the ISR and image chapters.</p>
<p>To reach the app from outside, you punch a port through — the app listens on 3000 <em>inside</em> a closed box:</p>
<pre><code class="language-bash">docker run -p 3000:3000 produsebaby:latest
</code></pre>
<p><code>-p 3000:3000</code> means "port 3000 on the VPS → port 3000 in the container." And to change settings without rebuilding the image, you pass environment variables (<code>-e KEY=value</code>), read in code as <code>process.env.KEY</code>. Secrets come in this way too — never baked into the image.</p>
<p>Two commands you will lean on constantly:</p>
<pre><code class="language-bash">docker ps          # what containers are running now
docker logs &lt;id&gt;   # what the app printed (your errors live here)
</code></pre>
<p>On Vercel you looked in a dashboard. Here you look in <code>docker logs</code>.</p>
<p>One honest tradeoff before we move on: the <code>COPY /app/public</code> line stuffs those ~597MB (images + Pagefind) straight into the image. Simple, one artifact, works first try. But every deploy then ships ~600MB, and you rebuild even when you add just a few products. For <strong>now, that's fine</strong> — you want something that works, so you can learn. We decouple <code>public/images</code> from the image later, so deploys get fast again. First simple that works, then fast.</p>
<hr />
<h2>Chapter 4 — The whole stack in one file: Compose and Caddy</h2>
<p>The command above already carries <code>-p</code>, several <code>-e</code> flags, soon volumes, plus a <em>second</em> container. That becomes a long, fragile line you don't want to type correctly every time.</p>
<p>So you stop typing it. Your site needs <strong>two</strong> containers working together:</p>
<pre><code class="language-plaintext">app    → your Next app (bun server.js, on 3000)
caddy  → the reverse proxy, which takes the public traffic and the HTTPS
</code></pre>
<p>Docker Compose writes all of it <strong>once, in a file</strong>, and starts everything with one command:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><code>-d</code> means "detached," runs in the background. One command, the whole stack up.</p>
<p>Here is the complete stack, the starter version:</p>
<pre><code class="language-yaml">services:
  app:
    build: .                      # builds from the Dockerfile
    restart: unless-stopped       # if it crashes / the VPS reboots → it comes back
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_SITE_URL=https://produsebaby.ro
    volumes:
      - nextjs_cache:/app/.next/cache   # ISR + optimized images survive redeploys
    expose:
      - "3000"                    # visible ONLY to other containers, not the internet

  caddy:
    image: caddy:2                # ready-made image, you build nothing
    restart: unless-stopped
    ports:
      - "80:80"                   # HTTP  → comes in from the internet
      - "443:443"                 # HTTPS → comes in from the internet
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro   # the proxy's config
      - caddy_data:/data          # where Caddy keeps TLS certificates (so it doesn't re-fetch)
      - caddy_config:/config
    depends_on:
      - app                       # start app before caddy

volumes:
  nextjs_cache:
  caddy_data:
  caddy_config:
</code></pre>
<p>Three ideas, that's all.</p>
<p><strong>1.</strong> <code>expose</code> <strong>vs</strong> <code>ports</code> <strong>— the security difference.</strong></p>
<pre><code class="language-plaintext">ports:  "443:443"  → open to the INTERNET. Anyone can reach it.
expose: "3000"     → open only to the other containers in the stack.
</code></pre>
<p>Only <code>caddy</code> has <code>ports</code>. Your app is hidden behind, on <code>expose</code>. Nobody outside hits Next directly — everyone goes through Caddy. That is exactly what you want.</p>
<pre><code class="language-plaintext">Internet → caddy :443 → app :3000
            (public)     (hidden)
</code></pre>
<p><strong>2. Volumes are the memory that stays.</strong> <code>nextjs_cache</code> links <code>/app/.next/cache</code> inside the container to space that lives on the VPS. New container at deploy → same volume → ISR and optimized images are still there, not from scratch. <code>caddy_data</code> does the same for the TLS certificates.</p>
<p><strong>3.</strong> <code>restart: unless-stopped</code> <strong>is self-restart.</strong> App crashes? VPS reboots? The containers come back automatically. On Vercel you never had this worry; here you solve it with one line.</p>
<p>And there is a hidden network: Compose automatically puts both containers on the same private network and names them. That is why, in the Caddy config next, you just write <code>app:3000</code> — <code>app</code> is the service name, and Docker translates it to the right container. No IPs, no headache.</p>
<p><strong>Now, what is Caddy actually doing at the front?</strong> Your app listens on port 3000, plain HTTP, hidden on the private network. The internet speaks on 443 (HTTPS) and 80 (HTTP), and demands a certificate. Something has to stand at the border and translate. That is the <strong>reverse proxy</strong>.</p>
<pre><code class="language-plaintext">Internet (443, HTTPS, wants a certificate)
↓
REVERSE PROXY  ← stands at the door
↓
app:3000 (plain HTTP, behind)
</code></pre>
<p>"Reverse" because it guards the servers, not the clients. It is the building's receptionist: it takes everyone off the street and sends them to the right office inside. Concretely it does four jobs — terminates HTTPS (puts the padlock on, decrypts, sends plain HTTP behind), fetches the certificate automatically and free from Let's Encrypt and renews it, routes everyone to <code>app:3000</code>, and compresses responses.</p>
<p>That second job is why we pick Caddy over the classic Nginx. With Nginx, HTTPS means installing certbot, writing config, adding a renewal cron — steps you can get wrong. Caddy does HTTPS <strong>automatically, first try</strong>. You give it the domain name; it requests the certificate itself, installs it, renews it.</p>
<pre><code class="language-plaintext">Nginx:  maximum power, you configure the certificates yourself.
Caddy:  90% of cases, HTTPS free and automatic, four lines of config.
</code></pre>
<p>Here is the entire <code>Caddyfile</code>. Four lines that matter:</p>
<pre><code class="language-caddy">produsebaby.ro {
    encode gzip zstd
    reverse_proxy app:3000
}

www.produsebaby.ro {
    redir https://produsebaby.ro{uri} permanent
}
</code></pre>
<p>The mere fact that you wrote the domain name in <code>produsebaby.ro { ... }</code> makes Caddy request the TLS certificate for it automatically. <code>reverse_proxy app:3000</code> forwards everything to the <code>app</code> container on the private network. The <code>www</code> block sends <code>www</code> visitors to the canonical no-www address (good for SEO).</p>
<p>So here is the back half of our request flow — the first arrows of a diagram we'll keep adding to:</p>
<pre><code class="language-plaintext">Internet
↓  :443, encrypted
caddy: checks the certificate, decrypts
↓  app:3000, plain HTTP, private network
app (Next) receives the request and starts working
</code></pre>
<p>Your app never knew about HTTPS. It got clean HTTP. Caddy carried all the encryption weight.</p>
<hr />
<h2>Chapter 5 — The pages that refresh themselves: ISR</h2>
<p>This is your big fear: "ISR is something Vercel does. If I leave, do I lose it?"</p>
<p>Short, reassuring answer:</p>
<blockquote>
<p>ISR is not Vercel magic. It is a Next.js feature. It runs just as well on your server. You only have to understand where it keeps its cache — and put that on a volume.</p>
</blockquote>
<p>ISR is Incremental Static Regeneration. Your stroller page goes through three states:</p>
<pre><code class="language-plaintext">revalidate = 86400   (from src/app/[category]/[slug]/page.tsx)
</code></pre>
<p>That means "this page is good for 24 hours; then, on the first request after, regenerate it in the background from fresh content." The three states:</p>
<pre><code class="language-plaintext">FRESH   (under 24h)  → serve from cache instantly. Zero work.
STALE   (over 24h)   → still serve from cache instantly (the user does NOT wait),
                       AND kick off regeneration in the background. The next user
                       gets the new version.
MISSING (never built) → generate now, put it in cache, serve it.
</code></pre>
<p>The key idea: the user almost never waits. They always get something from cache; the new version bakes in the background. Static speed plus content that refreshes.</p>
<p><strong>Where does the cache live?</strong> On disk, in <code>.next/cache</code>. This is the whole secret, and it is more boring than you feared. When Next generates the stroller page, it writes it as files, on disk, in <code>.next/cache/...</code> — an HTML file plus some metadata. A folder on disk.</p>
<pre><code class="language-plaintext">Request for /carucioare/cele-mai-bune-carucioare
↓
Next looks in .next/cache
↓
found + fresh?  → send the file. done.
found + stale?  → send the file NOW, regenerate in background, overwrite on disk
missing?        → generate, write to disk, send
</code></pre>
<p>On Vercel, that "disk" was their infrastructure, spread across the whole CDN. On your VPS, that "disk" is the container's disk. Same mechanism, different place.</p>
<p><strong>Who triggers the regeneration? A request — not a cron.</strong> A common confusion: you think a clock somewhere remakes the pages every 24 hours. No. Nothing happens until <strong>a request</strong> comes in for an expired page. That request is the trigger. It finds the stale page and starts the regeneration.</p>
<pre><code class="language-plaintext">No request = no regeneration. The stale page sits quietly on disk.
First request after expiry = pulls the new version in the background.
</code></pre>
<p>So <code>generateStaticParams</code> (which you already use on three routes) pre-generates the pages at build time, so the first visit is already fast. ISR refreshes them afterward, on demand.</p>
<p><strong>Now the bad version first.</strong> Here is the naive setup, with no volume:</p>
<pre><code class="language-plaintext">Deploy → new container from the new image → its disk is EMPTY
↓
.next/cache is empty
↓
every page is "MISSING" → first visit regenerates it from scratch
↓
every deploy, the entire ISR cache is lost
</code></pre>
<p>Nothing breaks — but on every deploy, your first visitors pay to regenerate every page. On a content-heavy site, you feel it.</p>
<p>The fix you already know: a <strong>volume</strong>. It's one line in the Compose file:</p>
<pre><code class="language-yaml">    volumes:
      - nextjs_cache:/app/.next/cache
</code></pre>
<p>It links <code>.next/cache</code> inside the container to space that lives on the VPS, outside the container.</p>
<pre><code class="language-plaintext">Deploy → new container → but .next/cache = the same volume as before
↓
the regenerated pages are still there
↓
ISR survives the deploy
</code></pre>
<p>One line. That is the whole difference between "ISR runs poorly on a VPS" and "ISR runs like it did on Vercel."</p>
<p><strong>One hidden trap: stay at a single container.</strong> The on-disk-files mechanism assumes <strong>one</strong> container writing to that <code>.next/cache</code>. If one day you put <strong>two</strong> <code>app</code> containers behind Caddy for more traffic, each has its own volume. Container A regenerates the page; container B still sees the old version. They desync.</p>
<p>For you, now: stay at one container. Static-first, small-to-medium traffic — one container carries it easily. Just so you know it exists: if you ever scale to several containers, Next 16 gives you a <code>cacheHandler</code> in <code>next.config.ts</code> that keeps the cache in a <em>shared</em> place (usually Redis) instead of each container's disk. Remember only the rule: <em>one container → volume on disk (simple, what you have). Several containers → shared cache (Redis, later, if ever).</em></p>
<p>Now the request flow grows — add ISR to the arrows from the last chapter:</p>
<pre><code class="language-plaintext">caddy hands the request to app:3000
↓
Next: /carucioare/cele-mai-bune-carucioare — I look in .next/cache (on the volume)
↓
fresh → instant HTML.  stale → instant HTML + regenerate in background.
↓
caddy encrypts the response, sends it back
↓
the parent sees the article — and now the images enter the scene
</code></pre>
<hr />
<h2>Chapter 6 — Images, sharp, and whether you need a CDN</h2>
<p>You have 2844 images. 404MB in <code>public/images</code>. All local — published products serve their photos from <code>public/</code>, not from an external CDN.</p>
<p>Two questions nag at you: <em>who optimizes them now</em> and <em>do I need a CDN?</em> Same running example, one thread.</p>
<p><strong>What "optimized" means.</strong> The stroller page asks for a photo. The original file might be 2MB, 2000px wide. <code>next/image</code> does not send it as is:</p>
<pre><code class="language-plaintext">Original:  carucior.jpg, 2000px, 2MB
Delivered to phone:  WebP, 640px, ~45KB
</code></pre>
<p>Resized to the screen, in a modern format, compressed. Forty times smaller.</p>
<p><strong>Where the optimization runs now — and this is the change.</strong> On Vercel, it ran on <em>their</em> servers. On a VPS, it runs <strong>in your own Node process</strong>, with the <code>sharp</code> library (already installed).</p>
<pre><code class="language-plaintext">Request: /_next/image?url=/images/carucior.jpg&amp;w=640&amp;q=75
↓
Next reads the source file from public/images
↓
sharp resizes it + converts to WebP   ← THIS eats CPU
↓
the result is written to .next/cache/images (on the volume!)
↓
sent to the user
</code></pre>
<p>Two things to keep from this. First, <strong>sharp eats CPU</strong> — the first request for a new size does real work (which is why the VPS chapter told you not to take the weakest box). Second, <strong>it happens ONCE.</strong> The result lands in <code>.next/cache/images</code>. The second visitor asking for the same photo at the same size gets it from cache, no work. And because <code>.next/cache</code> is on a volume, that cache survives deploys too. <code>minimumCacheTTL: 2678400</code> in your config (31 days) reinforces it: it tells the browser and CDN "this optimized photo is good for a month, don't ask again."</p>
<p><strong>Do you need a CDN? What it is, really.</strong> A CDN keeps <strong>copies of files in cities all over the world</strong>, close to users, so the photo comes from Frankfurt, not from your server.</p>
<pre><code class="language-plaintext">Without a CDN:  every visitor → hits your VPS → the VPS sends the photo
With a CDN:     first visitor → VPS. Everyone else → from the CDN cache, near them.
</code></pre>
<p>Good news: <strong>you already have a CDN.</strong> <code>produsebaby.ro</code> is on Cloudflare, and Cloudflare is a free CDN. You don't buy anything. You just let it do its job over your images.</p>
<p>Which is the single most important optimization here — Cloudflare caching the <em>optimized</em> photos:</p>
<pre><code class="language-plaintext">Without Cloudflare caching:
  every visitor → /_next/image → sharp works (or reads from disk)
  → your VPS works on every photo, for every visitor

With Cloudflare caching /_next/image:
  first visitor → sharp makes the photo once
  → Cloudflare holds it at the edge
  → thousands of visitors → straight from Cloudflare, the VPS never hears about it
</code></pre>
<p>So sharp runs <strong>once per (photo, size)</strong>, then Cloudflare serves everyone. Your VPS barely breathes. You set this in Cloudflare with a Cache Rule that caches the paths <code>/_next/image*</code> and <code>/images/*</code>. Cloudflare already respects the <code>Cache-Control</code> header Next sets (from <code>minimumCacheTTL</code>), so it often works from the start.</p>
<p><strong>The 600MB problem: don't bake images into the Docker image.</strong> In the packaging chapter we stuffed all of <code>public/</code> into the Docker image. Simple, but painful:</p>
<pre><code class="language-plaintext">The Docker image becomes ~600MB.
You add 5 new products → you rebuild and ship 600MB.
The images have nothing to do with the code, but you haul them on every code deploy.
</code></pre>
<p>The upgrade: <strong>keep the images on the VPS, outside the Docker image.</strong> They don't change on every code deploy, so they don't belong in the code artifact. Three moves.</p>
<p>First, put the images folder on the VPS once (e.g. <code>/srv/produsebaby/images</code>) and update it separately. Second, mount it into the container read-only, so the optimizer can read the photos:</p>
<pre><code class="language-yaml">  app:
    volumes:
      - nextjs_cache:/app/.next/cache
      - /srv/produsebaby/images:/app/public/images:ro   # ← photos live on the VPS, not in the Docker image
</code></pre>
<p>That line is a <strong>bind mount</strong>: <code>path_on_VPS : path_in_container : options</code>.</p>
<pre><code class="language-plaintext">/srv/produsebaby/images   real folder, on the VPS disk (the host)
/app/public/images        where that folder appears INSIDE the container
:ro                       read-only — the container only READS, never writes
</code></pre>
<p>The container sees the VPS folder as if it were its own. Not a copy — the same folder, viewed from two places. Third, drop the images from the Docker image so you stop baking them, in <code>.dockerignore</code>:</p>
<pre><code class="language-plaintext">public/images
</code></pre>
<p>Note that <code>.next/static</code> and the Pagefind index (<code>public/pagefind</code>) STAY in the image — those genuinely change with every build and must stay in sync with the code. Only <code>public/images</code> moves out, because it's large and independent of the code.</p>
<p><strong>How the images get onto the VPS:</strong> <code>rsync</code><strong>.</strong> You update the images "separately." With what? Here is the bad version first — copying the whole folder every time with <code>scp</code>:</p>
<pre><code class="language-bash">scp -r ./public/images root@188.34.xxx.xxx:/srv/produsebaby/images
</code></pre>
<p>The problem: <code>scp</code> doesn't look at what's already there. It sends <strong>all</strong> 404MB, from scratch, even if you added just 8 new stroller photos. 404MB on the wire, for 8 photos. Every time. Slow, and pointless.</p>
<p><code>rsync</code> looks at <strong>both</strong> sides first — what you have locally and what's already on the VPS — compares, and sends only the difference.</p>
<pre><code class="language-plaintext">Your laptop: 2852 images
VPS:         2844 images
↓ rsync compares
sends only the 8 new ones
↓
VPS: 2852 images
</code></pre>
<p>The real command:</p>
<pre><code class="language-bash">rsync -avz --delete ./public/images/ root@188.34.xxx.xxx:/srv/produsebaby/images/
</code></pre>
<pre><code class="language-plaintext">-a        "archive": keep folder structure, dates, permissions
-v        "verbose": show which files it sends
-z        "zip": compress on the way, fewer bits on the wire
--delete  what you deleted locally, delete on the VPS too (exact mirror)
./public/images/   source (local) — the trailing / matters: "the folder's contents"
root@...:/srv/...  destination (on the VPS, over SSH)
</code></pre>
<p>You run it whenever you add or change product images locally. Only the new bits fly — no Docker rebuild, no code deploy. Images and code become two separate streams, exactly what you wanted. (Be careful with <code>--delete</code>: it makes the VPS an <em>exact</em> mirror of your local folder. The first time, run it without <code>--delete</code>, or add <code>--dry-run</code>, which shows what it <em>would</em> do without touching anything.)</p>
<p>Now the flow grows again — the image path folded in:</p>
<pre><code class="language-plaintext">The stroller page asks for the photos
↓
/_next/image?url=/images/carucior.jpg&amp;w=640
↓
Cloudflare has the optimized copy?  → sends it. The VPS is not even bothered.
↓ (if not)
Next + sharp make it once, put it in .next/cache/images (volume), send it
↓
Cloudflare holds it for everyone else
↓
the parent sees the strollers, fast
</code></pre>
<hr />
<h2>Chapter 7 — Getting the domain to the server: DNS and HTTPS</h2>
<p>Until now, the request started magically heading for the server. This chapter explains the <em>first step</em>, the one that was missing: how the phone knows where "produsebaby.ro" is.</p>
<p><strong>DNS is the phone book of the internet.</strong> Computers don't find each other by name, but by number (IP address). DNS translates.</p>
<pre><code class="language-plaintext">"produsebaby.ro" = ?
↓ DNS question
"188.34.xxx.xxx"  ← your VPS's IP
↓
the phone connects to that number
</code></pre>
<p>That translation lives in an <strong>A record</strong>: name → IP.</p>
<pre><code class="language-plaintext">A record:  produsebaby.ro → 188.34.xxx.xxx
</code></pre>
<p>Right now your A record points to Vercel. The migration, in essence, is <strong>changing the IP in the A record</strong> to point to your VPS. That's it. The rest of the internet picks up the change on its own.</p>
<p>You make the change on Cloudflare, which you already use. You don't add a new provider — you just edit the A record:</p>
<pre><code class="language-plaintext">produsebaby.ro       A     188.34.xxx.xxx   (the VPS IP)
www.produsebaby.ro   A     188.34.xxx.xxx   (same, Caddy redirects it)
</code></pre>
<p>The change propagates in minutes to hours (usually minutes on Cloudflare).</p>
<p><strong>The cloud: DNS-only (grey) vs Proxied (orange).</strong> Next to each record, Cloudflare has a little cloud. This is the whole nuance, and it is simple once you see it.</p>
<p><strong>Grey (DNS-only):</strong> Cloudflare just answers "the IP is X." The visitor connects <em>directly</em> to your VPS.</p>
<pre><code class="language-plaintext">Phone → Cloudflare (only says the IP) → straight to the VPS
</code></pre>
<p><strong>Orange (Proxied):</strong> the traffic goes <em>through</em> Cloudflare. This is where you get the CDN and the image cache from the last chapter.</p>
<pre><code class="language-plaintext">Phone → Cloudflare (CDN, cache) → VPS
</code></pre>
<p>You <strong>want</strong> orange in the end, so it caches the images. But at <em>first startup</em>, orange complicates Caddy's certificate. That's why the order matters.</p>
<p><strong>HTTPS: who puts the padlock on, and the two-layer trap.</strong> Recall that Caddy requests the certificate itself from Let's Encrypt. To grant it, Let's Encrypt verifies by hitting <strong>port 80</strong> of the domain. The problem with orange on from the start:</p>
<pre><code class="language-plaintext">Orange ON → Let's Encrypt hits port 80, but lands on Cloudflare, not Caddy
→ Caddy can't prove it owns the domain → no certificate → HTTPS fails
</code></pre>
<p>Plus, with orange you have <em>two</em> TLS layers (visitor↔Cloudflare and Cloudflare↔VPS) which, badly configured, fight each other ("too many redirects"). So do exactly this order:</p>
<pre><code class="language-plaintext">1. In Cloudflare: A record → the VPS IP. Leave the cloud GREY (DNS-only).
2. Start the stack: docker compose up -d
   → Caddy hits Let's Encrypt unobstructed (port 80 reaches it directly)
   → gets the certificate, HTTPS works
3. Verify: open https://produsebaby.ro — padlock, site OK, straight from the VPS.
4. NOW turn the cloud ORANGE on the records.
5. In Cloudflare → SSL/TLS mode → "Full (strict)".
   → visitor↔Cloudflare encrypted, Cloudflare↔VPS encrypted, no quarrels.
6. Verify again. Now you also have Cloudflare's CDN in front (image cache).
</code></pre>
<p>Once fetched, Caddy keeps the certificate on the <code>caddy_data</code> volume, so it doesn't re-request it on every start, and it handles renewal by itself.</p>
<p>Now the flow finally grows a <em>front</em> — the first step is no longer magic:</p>
<pre><code class="language-plaintext">Phone: "where is produsebaby.ro?"
↓  DNS (Cloudflare) → the VPS IP
↓  (orange) through Cloudflare — image cache
↓  :443
Caddy on the VPS: certificate valid, decrypt
↓  app:3000
Next: ISR from .next/cache
↓
Cloudflare caches, the parent sees the page
</code></pre>
<hr />
<h2>Chapter 8 — Shipping code: manual deploy, then GitHub Actions</h2>
<p>You wrote new code on your laptop. How does it go live?</p>
<p>On Vercel: <code>git push</code>, done. Vercel caught the push, built, started. Invisible. On a VPS you have to know what was actually happening there, to rebuild it. Every deploy, however fancy, is the same four steps:</p>
<pre><code class="language-plaintext">1. The new CODE reaches the server        (git pull, or a ready-made image)
2. The new image is BUILT                  (the Dockerfile)
3. The old container is REPLACED           with one from the new image
4. The volumes STAY                        (ISR, images, certificates — intact)
</code></pre>
<p>Remember these four and you won't get lost.</p>
<p><strong>Method 1: build on the server (the simplest — start here).</strong> The code lives on the VPS as an ordinary git repo. Once, at the start, you clone it. Then every deploy is:</p>
<pre><code class="language-bash">ssh root@188.34.xxx.xxx
cd /srv/produsebaby
git pull                        # step 1: the new code
docker compose up -d --build    # steps 2+3: rebuild the image, replace the container
</code></pre>
<p><code>--build</code> rebuilds the image, then Compose stops the old container and starts a new one from the fresh image. The volumes pass through untouched.</p>
<pre><code class="language-plaintext">Good:  simple, nothing to configure, you see everything happen.
Bad:   the build runs ON the VPS → eats its RAM (that's why 4GB).
       And you type commands by hand on every deploy.
</code></pre>
<p>Secrets never go in git. On the VPS you put them in a <code>.env</code> file next to <code>docker-compose.yml</code> (a file that is NOT in git), and Compose reads it automatically.</p>
<p><strong>Method 2: build elsewhere, ship a ready-made image (registry).</strong> Method 1 has a flaw: the heavy build runs on your small VPS, while it is serving visitors. The alternative: build the image <strong>somewhere else</strong>, push it to a <strong>registry</strong> (a Docker image warehouse — e.g. GitHub Container Registry, <code>ghcr.io</code>), and on the VPS just <strong>pull it ready-made</strong>.</p>
<pre><code class="language-plaintext">Somewhere powerful (laptop / GitHub)     Your VPS
────────────────────────────────────     ─────────────
build the image
push to ghcr.io          ───────►         pull from ghcr.io
                                          docker compose up -d
                                          (no local build!)
</code></pre>
<p>On the VPS, the deploy becomes:</p>
<pre><code class="language-bash">docker compose pull    # pull the new, already-built image
docker compose up -d   # replace the container. Zero build on the VPS.
</code></pre>
<p>In <code>docker-compose.yml</code>, the difference is one line: instead of <code>build: .</code> you point to the registry image, tagged with the <strong>commit</strong>, not <code>:latest</code>:</p>
<pre><code class="language-yaml">  app:
    image: ghcr.io/your-user/produsebaby:${IMAGE_TAG:-latest}   # instead of "build: ."
</code></pre>
<p><code>${IMAGE_TAG:-latest}</code> means "use the <code>IMAGE_TAG</code> variable, and if it's not set, fall back to <code>latest</code>." Why bother? <strong>Rollback in 10 seconds.</strong> Something broke? On the VPS:</p>
<pre><code class="language-bash">IMAGE_TAG=&lt;yesterday's-good-commit&gt; docker compose up -d
</code></pre>
<p>and you're back to the version that worked, no rebuild, no git. Each commit has its own image in the registry, ready to restart. With <code>:latest</code> you'd have nothing to go back to — every version wore the same tag.</p>
<p><strong>Now automate it: GitHub Actions.</strong> Doing the deploy by hand works, but it's tiring and easy to get wrong at 2 a.m. GitHub Actions does those steps <strong>by itself, on every</strong> <code>git push</code> — exactly what Vercel did, except now you wrote the recipe once. That's CI/CD:</p>
<pre><code class="language-plaintext">CI (Continuous Integration): on every push, build and check automatically.
CD (Continuous Deployment):  if it's OK, ship to the server automatically.
</code></pre>
<p><strong>What runs the recipe: a "runner."</strong> When you push, GitHub starts a <strong>temporary, free computer of theirs</strong> — a clean Ubuntu that lives for the ~3 minutes your job runs, then vanishes.</p>
<pre><code class="language-plaintext">git push
↓
GitHub starts a runner (clean Ubuntu, on their side, free)
↓
the runner: builds the image, pushes it to the registry, calls the VPS to pull the new version
↓
the runner self-destructs
</code></pre>
<p>The beauty: the heavy build happens on <em>their</em> computer, not your small VPS. The VPS just pulls the ready image (method 2). Here is the recipe, <code>.github/workflows/deploy.yml</code>:</p>
<pre><code class="language-yaml">name: Deploy

on:
  push:
    branches: [main]        # triggers on push to main

# If you push twice fast, cancel the old run and keep only the latest.
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest  # the runner: a clean Ubuntu from GitHub
    steps:
      # 1. Get the code into the runner
      - uses: actions/checkout@v4

      # 2. Log in to the registry (GitHub Container Registry)
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # given automatically by GitHub

      # 3. Prepare buildx (the build engine with layer cache)
      - uses: docker/setup-buildx-action@v3

      # 4. Build the image and push it to the registry.
      #    tag = the commit (github.sha), not :latest → traceable + easy rollback.
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # 5. Call the VPS over SSH and restart with that commit's image
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: root
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /srv/produsebaby
            export IMAGE_TAG=${{ github.sha }}   # exactly the commit built now
            docker compose pull                  # pull that commit's image
            docker compose up -d                 # replace the container
</code></pre>
<p>Read the steps like a story: <em>get the code → log in to the warehouse → prepare buildx → build and push the image (tagged with the commit) → call the server to pull exactly that commit.</em> Exactly the manual deploy, written once. Three small lines do a lot of work:</p>
<p><strong>1. Tag with</strong> <code>github.sha</code><strong>, not</strong> <code>:latest</code><strong>.</strong> <code>github.sha</code> is the hash of the pushed commit. <code>:latest</code> means "the latest" — which latest? You don't know what code is inside. <code>:&lt;commit-sha&gt;</code> means this image <em>is</em> this code. Traceable (you can always say exactly what runs in production) and easy to roll back. That's why the deploy step does <code>export IMAGE_TAG=${{ github.sha }}</code> before <code>docker compose pull</code>.</p>
<p><strong>2. Docker layer cache → fast builds.</strong> <code>cache-from</code> / <code>cache-to: type=gha</code> save the Docker image layers in the GitHub Actions cache, between runs. First run: <code>bun install</code> runs from scratch. Later runs: if <code>bun.lock</code> hasn't changed, the <code>bun install</code> layer is reused from cache. Every Dockerfile line is a layer; the cache reuses the unchanged ones, on clean runners that would otherwise start from zero every time.</p>
<p><strong>3.</strong> <code>concurrency: cancel-in-progress</code> <strong>→ no overlapping deploys.</strong> You push, spot a mistake, push again fast. Without this, you'd have two deploys running in parallel, racing to reach the VPS last. With it, the new run kills the old one. Only the latest push goes live.</p>
<p><strong>Where the passwords live.</strong> You never put passwords in the <code>.yml</code> (it goes into git, everyone sees it). You put them in GitHub → repo → Settings → Secrets, and call them with <code>${{ secrets.NAME }}</code>. You need two, plus one free:</p>
<pre><code class="language-plaintext">VPS_HOST      = the VPS IP
VPS_SSH_KEY   = the private SSH key the runner uses to enter the VPS
GITHUB_TOKEN  = GitHub gives it to you automatically, you don't set it
</code></pre>
<p>And there is the SSH key shape from the VPS chapter — <em>something secret with you, its public pair on the server</em> — except now the runner plays the role of "you":</p>
<pre><code class="language-plaintext">The PRIVATE key  → in GitHub Secrets (VPS_SSH_KEY). Only the runner uses it.
The PUBLIC key   → placed on the VPS (in ~/.ssh/authorized_keys).
</code></pre>
<p>That is how the temporary runner logs into the VPS with no password, exactly like you, but automatically. What you gained, concretely:</p>
<pre><code class="language-plaintext">Before:  git push → ssh VPS → cd → git pull → docker compose up --build → wait
With Actions:  git push → (go make a coffee) → it's live
</code></pre>
<p>You rebuilt the Vercel experience. You push, the rest happens on its own. Except now you know <em>what</em> happens, on every line.</p>
<hr />
<h2>Chapter 9 — The whole picture</h2>
<p>You've seen every piece separately. Now put them in one picture and follow the parent's request from end to end, without skipping a single link.</p>
<pre><code class="language-plaintext">The parent types produsebaby.ro/carucioare/cele-mai-bune-carucioare
│
▼  1. DNS (Cloudflare): "produsebaby.ro" → the VPS IP
│
▼  2. Cloudflare (orange cloud): CDN + image cache
│       └─ has the page/photo at the edge? → sends it, done.
│
▼  3. Your VPS, port 443
│
▼  4. Container `caddy`: verify the TLS certificate, decrypt
│       └─ reverse_proxy app:3000
│
▼  5. Container `app` (Next, bun server.js, port 3000)
│       ├─ page in .next/cache?  ISR: fresh→instant, stale→instant+regenerate
│       └─ photos: /_next/image → sharp once → .next/cache/images
│
▼  6. The volumes remember between deploys: nextjs_cache, caddy_data
│
▼  the response goes back up through Caddy → Cloudflare (caches) → phone
│
▼  the parent sees the strollers article. Fast.
</code></pre>
<p>All six services Vercel had glued together are there, now yours, each with its own name.</p>
<p>The whole migration fits in five new files plus one changed line:</p>
<pre><code class="language-plaintext">produsebaby.ro/
├── next.config.ts              ← ADD: output: 'standalone'
├── Dockerfile                  ← NEW: the image recipe (bun, multi-stage)
├── .dockerignore               ← NEW: what not to put in the image
├── docker-compose.yml          ← NEW: app + caddy + volumes
├── Caddyfile                   ← NEW: domain + HTTPS + proxy
└── .github/workflows/deploy.yml ← NEW: auto-deploy on push
</code></pre>
<p>Plus, on the VPS (not in git): a <code>.env</code> with secrets and the <code>/srv/produsebaby/images</code> folder. And the plan, in order — each step leans on the one before:</p>
<pre><code class="language-plaintext">Prepare (on the laptop)
1. Add output: 'standalone' to next.config.ts
2. Create Dockerfile, .dockerignore, docker-compose.yml, Caddyfile
3. Test LOCALLY: docker compose up --build → open localhost
   (if it works locally, it works on the VPS — that's the whole point of Docker)

The server (once)
4. Rent a VPS (2 vCPU / 4GB / ~40-80GB), get an IP
5. Connect: ssh root@IP, install Docker
6. Put your public SSH key on the VPS
7. git clone into /srv/produsebaby, create .env with the secrets
8. Upload the images to /srv/produsebaby/images (rsync)

Startup + domain
9.  docker compose up -d  → Caddy gets the certificate, the site comes up
10. In Cloudflare: A record → the VPS IP, GREY cloud
11. Verify https://produsebaby.ro directly from the VPS
12. Turn the cloud ORANGE + SSL "Full (strict)"
13. Cache Rule on Cloudflare for /_next/image and /images

Automation (last, once manual works)
14. Add .github/workflows/deploy.yml
15. Put the secrets in GitHub: VPS_HOST, VPS_SSH_KEY
16. git push → automatic deploy. You've rebuilt Vercel.
</code></pre>
<p>Don't jump to step 14 until the <strong>manual</strong> deploy works. First understand what you're automating, then automate it.</p>
<p><strong>What you gain and lose versus Vercel.</strong> Honestly, so you decide with open eyes:</p>
<table>
<thead>
<tr>
<th></th>
<th>Vercel</th>
<th>VPS + Docker</th>
</tr>
</thead>
<tbody><tr>
<td>Setup effort</td>
<td>zero</td>
<td>you build it all</td>
</tr>
<tr>
<td>Cost</td>
<td>grows with traffic</td>
<td>fixed, ~a few–20 €/month</td>
</tr>
<tr>
<td>Control</td>
<td>limited to the platform</td>
<td>total (you have root)</td>
</tr>
<tr>
<td>Build</td>
<td>on their side</td>
<td>on yours (or GitHub's runner)</td>
</tr>
<tr>
<td>When something breaks</td>
<td>they fix it</td>
<td>you fix it (docker logs)</td>
</tr>
<tr>
<td>What you learn</td>
<td>nothing about infra</td>
<td>real DevOps</td>
</tr>
</tbody></table>
<p>It is not universally "better." It is a trade: convenience for control, fixed cost, and understanding. And when should you <em>not</em> do this? If your site might explode in traffic tomorrow and you have no time for any of it, Vercel carries you without touching any of these files. You pay precisely so you're never on call. A VPS asks <em>you</em> to be the admin: certificate renewal (Caddy does it, but you check), server security, backups. Not hard, but yours.</p>
<p>Here is the final test. If you read all of this, you should be able to say, without looking:</p>
<blockquote>
<p>First, DNS on Cloudflare translates my domain into the VPS's IP.</p>
<p>Cloudflare sits in front and caches the images, then sends the request to the VPS on 443.</p>
<p>Caddy receives it, puts HTTPS on, and hands it to the Next container on 3000.</p>
<p>Next looks in <code>.next/cache</code> (on a volume) and either serves the ready page or regenerates it in the background — that's ISR, and it runs on my server, not just on Vercel.</p>
<p>The photos are optimized by sharp once, kept in cache, and Cloudflare serves them to everyone else.</p>
<p>And when I <code>git push</code>, GitHub Actions rebuilds the image and restarts the container, by itself.</p>
</blockquote>
<p>If you can say that, you learned the DevOps you needed. Not some words. The whole flow.</p>
<p>Because that was the trade all along:</p>
<pre><code class="language-plaintext">Vercel glued six services into one button.
You pulled them apart and put them back on a bare computer, each with its name.
You gave up the convenience, and took the control, the fixed cost, and the understanding.
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Don't throw your layout into the bin]]></title><description><![CDATA[Layout thrashing, or how reading a number can freeze your page
First of all, you can play with the layout trashing here.
Here is a number that should not be possible.
Two loops. Same work. The same 2,]]></description><link>https://featuringcode.com/don-t-throw-your-layout-into-the-bin</link><guid isPermaLink="true">https://featuringcode.com/don-t-throw-your-layout-into-the-bin</guid><category><![CDATA[layout]]></category><category><![CDATA[layout trashing]]></category><category><![CDATA[performance]]></category><category><![CDATA[Performance Optimization]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Thu, 02 Jul 2026 19:40:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/901ebf4c-9dcc-4967-8751-b51be0e1ce9f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Layout thrashing, or how reading a number can freeze your page</h1>
<p>First of all, you can play with the layout trashing <a href="https://layout-trashing.vercel.app/">here</a>.</p>
<p>Here is a number that should not be possible.</p>
<p>Two loops. Same work. The same 2,000 elements.</p>
<p>One takes <strong>4.9 milliseconds</strong>.</p>
<p>The other takes <strong>2,504 milliseconds</strong>.</p>
<p>That is 511 times slower. For the same result.</p>
<p>The slow one didn't do more work. It did the same work in the wrong <em>order</em>. It read a value at the wrong moment.</p>
<p>That is layout thrashing.</p>
<p>By the end of this you'll be able to look at a loop and say, out loud, exactly when the browser is about to freeze — and why.</p>
<h2>The one question underneath</h2>
<p>You write this:</p>
<pre><code class="language-js">const height = box.offsetHeight;
</code></pre>
<p>You just read a number. A property. It looks free.</p>
<p>It is not free.</p>
<p>In the wrong loop, that one line is slower than a network call.</p>
<p>So here is the question this whole post answers:</p>
<pre><code class="language-plaintext">How can *reading* a value off an element
be the slowest thing your page does?
</code></pre>
<p>To answer it, you have to know what the browser is doing behind that read. So let's follow a frame.</p>
<h2>The pipeline — what happens between your code and a pixel</h2>
<p>Your JavaScript does not draw pixels.</p>
<p>It changes a description of the page. The browser turns that description into pixels, in stages, in order:</p>
<pre><code class="language-plaintext">Your JavaScript
↓
Style      (which CSS rules apply to which elements?)
↓
Layout     (where is every box, and how big?)
↓
Paint      (what color is every pixel — as a list of draw commands)
↓
Composite  (stitch the painted layers together on the GPU)
</code></pre>
<p>Four stages after your code runs. Each one takes the output of the last.</p>
<p>Now the part most explanations skip. <strong>Where does each stage run?</strong></p>
<pre><code class="language-plaintext">Your JavaScript    → main thread
Style              → main thread
Layout             → main thread
Paint (record)     → main thread
Raster + Composite → compositor thread + GPU
</code></pre>
<p>The first four share one thread. The same thread that runs your code. The same thread that handles clicks and keystrokes.</p>
<p>If that thread is busy, nothing else happens. No frame. No response to input. The page is frozen.</p>
<p>Hold onto that line. It is the whole story.</p>
<h2>What "Layout" actually computes</h2>
<p>Style decides <em>which</em> rules apply.</p>
<p>Layout decides <em>where everything goes</em>.</p>
<p>Given the tree of boxes and their styles, Layout computes the geometry: for every element, its x, y, width, and height. Where it sits. How big it is.</p>
<p>The data going in: a tree of boxes plus their styles.</p>
<p>The data coming out: the same tree, now with a number for every position and size.</p>
<p>This is expensive, and it is not local. Boxes affect each other. Make one wider and its siblings shift. Its parent grows. On a page with a thousand boxes, Layout has to place a thousand boxes, together.</p>
<p>So the browser does something clever to avoid running it too often.</p>
<h2>The browser is lazy on purpose</h2>
<p>The browser does not recompute Layout every time you touch the DOM.</p>
<p>That would be madness. You might change ten things in one function.</p>
<p>So when you write something that could change geometry:</p>
<pre><code class="language-js">box.style.width = "200px";
</code></pre>
<p>the browser does <em>not</em> recompute layout.</p>
<p>It makes a note: <em>layout is dirty now.</em></p>
<p>Then it goes back to running your code.</p>
<p>It is waiting. It wants to batch. The plan is to recompute layout <strong>once</strong>, later — right before it paints the next frame, after your code is done making its mess.</p>
<pre><code class="language-plaintext">write, write, write   → "dirty, dirty, dirty"   (no layout yet)
...your code finishes...
↓
Layout runs once
↓
Paint
</code></pre>
<p>One layout per frame, no matter how many writes. That is the plan.</p>
<p>The up-to-date geometry is <em>clean</em>, and the browser keeps it cached. A write marks it <em>dirty</em>. Clean layout is stored and reused. Dirty layout is a promise to recompute — later.</p>
<p>Later. Not now.</p>
<p>That word is doing all the work.</p>
<h2>The trap: when "later" becomes "right now"</h2>
<p>Here is the naive loop. It looks completely reasonable.</p>
<p>You have a list of boxes. You want to double each one's height.</p>
<pre><code class="language-js">for (const box of boxes) {
  const height = box.offsetHeight;       // READ
  box.style.height = height * 2 + "px";  // WRITE
}
</code></pre>
<p>Read the height. Write double. Next box.</p>
<p>You might think reading <code>offsetHeight</code> is cheap. It's just a property.</p>
<p>But watch what it does to the browser's plan.</p>
<p>The browser wanted to batch layout for later. Then you ask for <code>offsetHeight</code>.</p>
<p><code>offsetHeight</code> is a geometry value. To hand you a truthful answer, the browser needs layout to be clean. But you dirtied it one line ago, when you wrote <code>style.height</code> on the previous box.</p>
<p>So the browser has no choice. It will not give you a stale number. It stops, and recomputes layout <strong>right now</strong> — synchronously, in the middle of your loop — just to answer your read.</p>
<p>That is a forced synchronous layout. A forced reflow.</p>
<p>Then the next line writes <code>style.height</code> again. Dirty again.</p>
<p>Next iteration reads <code>offsetHeight</code>. Forced reflow again.</p>
<pre><code class="language-plaintext">write → read → reflow → write → read → reflow → ...
</code></pre>
<p>For a thousand boxes, that is a thousand full layouts, in a single frame. The browser is running the most expensive stage of its pipeline over and over — because you keep asking it a question it can only answer by doing the work.</p>
<p>That is the thrash.</p>
<h2>The fix is just reordering</h2>
<p>You don't need to do less work. You need to stop interleaving.</p>
<p>Do all the reads first. Then all the writes.</p>
<pre><code class="language-js">// read everything while layout is still clean
const heights = boxes.map((box) =&gt; box.offsetHeight);

// now write everything — this dirties layout, but nobody reads it back
boxes.forEach((box, i) =&gt; {
  box.style.height = heights[i] * 2 + "px";
});
</code></pre>
<p>The reads happen while layout is clean, so they're free — the browser hands back cached numbers.</p>
<p>Then the writes happen together. They dirty layout. But nothing reads it back before the loop ends. So the browser keeps its promise: it recomputes layout <strong>once</strong>, afterward, before the next paint.</p>
<p>A thousand forced reflows became one.</p>
<p>Same boxes. Same result. These are the two numbers from the top:</p>
<pre><code class="language-plaintext">thrash  (read, write, read, write):   2,504 ms
batched (read all, then write all):       4.9 ms
</code></pre>
<p>511 times faster. By moving the reads above the writes.</p>
<h2>So, the definition</h2>
<p>Layout thrashing is reading a layout value after you've written one, in a loop, forcing the browser to recompute layout every time.</p>
<p>That is all it is.</p>
<p>It is not a browser bug. The browser is doing exactly what you asked: handing you a truthful, up-to-date number. Every single time you ask.</p>
<h2>When a read after a write is fine</h2>
<p>Now don't over-correct.</p>
<p>You could read all this and start fearing every <code>offsetHeight</code>.</p>
<p>Here is the auto-growing textarea you have probably written:</p>
<pre><code class="language-ts">const handleInput = (e) =&gt; {
  const el = textareaRef.current;
  if (!el) return;
  el.style.height = "auto";                  // WRITE — reset so it can shrink
  el.style.height = el.scrollHeight + "px";  // READ (one flush) + WRITE
};
</code></pre>
<p>There is a write, then a read, then a write. By the letter of the definition, that read forces a reflow.</p>
<p>And it is completely fine.</p>
<p>It forces layout once. On one element. When the user types a character.</p>
<p>Thrashing is not "a read after a write." It is a read after a write <em>repeated</em> — in a loop, across many elements, many times per frame. One flush on one textarea is a rounding error. A thousand flushes in one loop is a frozen page.</p>
<p>The scale is the whole difference.</p>
<p>If you want it tidy, do the read-once move anyway. It costs nothing and it reads better:</p>
<pre><code class="language-ts">el.style.height = "auto";       // write
const next = el.scrollHeight;   // read once
el.style.height = next + "px";  // write
</code></pre>
<p>The rule that actually matters: don't go on to read <em>more</em> layout — <code>offsetHeight</code>, <code>getBoundingClientRect()</code> — later in the same handler, especially in a loop. One localized read-write pair is not the enemy. Interleaving is.</p>
<h2>The reads that spring the trap</h2>
<p>The dangerous read isn't only <code>offsetHeight</code>.</p>
<p>It's any value that depends on layout. Ask for one while layout is dirty, and you force a reflow.</p>
<p>There's a canonical list (Paul Irish maintains it). The ones you actually hit:</p>
<ul>
<li><p><code>offsetTop</code>, <code>offsetLeft</code>, <code>offsetWidth</code>, <code>offsetHeight</code></p>
</li>
<li><p><code>clientTop</code>, <code>clientWidth</code>, <code>clientHeight</code></p>
</li>
<li><p><code>scrollTop</code>, <code>scrollWidth</code>, <code>scrollHeight</code></p>
</li>
<li><p><code>getBoundingClientRect()</code></p>
</li>
<li><p><code>window.innerWidth</code>, <code>window.innerHeight</code> — yes, even these</p>
</li>
<li><p><code>getComputedStyle(el)</code> — when you read a layout-dependent value off it</p>
</li>
</ul>
<p>That last one is the sneaky one.</p>
<p><code>getComputedStyle</code> looks like it just reads CSS. Passive. Harmless. But ask it for <code>.height</code> while layout is dirty, and it forces the exact same reflow <code>offsetHeight</code> does.</p>
<p>People sprinkle <code>getComputedStyle</code> through render loops thinking it's a free lookup.</p>
<p>It is not free.</p>
<p>It is not a passive CSS read.</p>
<p>It forces layout, same as <code>offsetHeight</code> — just wearing a different coat.</p>
<h2>The expensive part is the call, not the value</h2>
<p>Here is the part that saves you.</p>
<p><code>getBoundingClientRect()</code> forces layout when you <em>call</em> it. Once, and only if layout was dirty.</p>
<p>What it hands back is a <code>DOMRect</code> — a plain object, frozen at that moment:</p>
<pre><code class="language-ts">const rect = container.getBoundingClientRect(); // may force layout — once
</code></pre>
<p>That <code>rect</code> is a snapshot. It is detached from the live page. Reading from it later is just reading an object in memory:</p>
<pre><code class="language-ts">rect.height; // free — no layout
rect.top;    // free
rect.width;  // free
</code></pre>
<p>You might think every <code>rect.height</code> re-measures the element.</p>
<p>It does not.</p>
<p>The measuring already happened, at the call. The object does not update itself when the page changes underneath it.</p>
<p>So the pattern that avoids thrashing is not "never read geometry." It is:</p>
<pre><code class="language-plaintext">Call once. Store the rect. Read from the snapshot.
</code></pre>
<p>One <code>getBoundingClientRect()</code> at the top of your frame, cached in a variable, can feed a hundred later reads for free. The trap is calling it a hundred times — once per element, interleaved with writes.</p>
<h2>Where thrashing loves to hide: scroll handlers</h2>
<p>The loop doesn't have to be a <code>for</code> loop you wrote.</p>
<p>The worst one fires on scroll.</p>
<pre><code class="language-js">scroller.addEventListener("scroll", () =&gt; {
  for (const box of boxes) {
    const rect = box.getBoundingClientRect(); // forced reflow, ×N
    // ...work out whether this box is on screen
  }
});
</code></pre>
<p>Scroll events fire fast — many times a second, in the gaps between frames. Each one reads <code>getBoundingClientRect()</code> for every box. Each read forces layout.</p>
<p>So the user drags, and every scroll tick is doing hundreds of reflows. The scroll stutters. The thing they're touching is the thing that's frozen.</p>
<p>The fix is the same shape: read once and cache, or throttle to one read per frame.</p>
<p>But this handler at least contains its own crime — the reads and the loop are right there to review. The worst version of this bug doesn't.</p>
<h2>The write you can't see: thrashing across frames</h2>
<p>Here is a real one, from a production site.</p>
<p>The table of contents highlights the heading you're currently reading. A scroll handler finds it:</p>
<pre><code class="language-tsx">const handleScroll = () =&gt; {
  for (const heading of headings) {
    const rect = heading.getBoundingClientRect(); // READ — clean, right?
    // ...find the heading nearest the top of the viewport
  }
  setActiveHeadingId(nearest); // React state update. Not a DOM write... yet.
};
</code></pre>
<p>Now look for the write→read interleave.</p>
<p>It is not there.</p>
<p>All the reads happen first. Then one <code>setState</code>. Inside this function, the ordering is textbook-correct.</p>
<p>But <code>setState</code> is a <em>deferred</em> write. React re-renders and commits the new highlight class to the DOM after the handler returns — in the gap between this scroll event and the next one.</p>
<p>So the timeline across events looks like this:</p>
<pre><code class="language-plaintext">scroll event 1:  reads (layout clean — cheap) → setState
↓
React commits the highlight class   → layout is dirty now
↓
scroll event 2:  first read         → FORCED REFLOW
↓
React commits the next highlight    → dirty again
↓
scroll event 3:  first read         → forced reflow again…
</code></pre>
<p>No single function contains the anti-pattern. The write and the read live in different frames. The interleaving only exists across time — which is exactly why it survives code review. Every piece, reviewed alone, is correct.</p>
<p>Measured on that TOC, with a heading list about twenty items long: <strong>25 layout reads per frame</strong> while scrolling, and <strong>60% of frames</strong> doing forced-layout work.</p>
<p>The fix didn't reorder the reads. It deleted them.</p>
<p><code>IntersectionObserver</code> lets you describe the zone you care about once — and then the <em>browser</em> tells you when things cross it:</p>
<pre><code class="language-ts">const observer = new IntersectionObserver(onHeadingsCrossed, {
  rootMargin: "0px 0px -66% 0px", // only the top third counts as "being read"
});
headings.forEach((heading) =&gt; observer.observe(heading));
</code></pre>
<p>The observer computes intersections off the hot path and calls back with ready-made entries — each one already carrying a <code>boundingClientRect</code>, precomputed, free. The handler flips a class and never asks the DOM a geometry question.</p>
<p>Same TOC, after the refactor: <strong>3.6 reads per frame. 6% thrashing frames instead of 60%.</strong> Same highlight, same UX.</p>
<p>And notice what the fix did <em>not</em> do: it did not stop writing. The highlight still moves, the class flip still dirties layout, and the browser still reflows once before the next paint — exactly as designed.</p>
<p>Writes are unavoidable. The page has to change; that is the point of the page.</p>
<p>What you can avoid is asking geometry questions while the answer is being recomputed. Stop the asking, and the writes go back to being what the browser always wanted them to be: batched, once, right before paint.</p>
<h2>The real fix, generalized: reads now, writes later</h2>
<p>The loop fix was "all the reads, then all the writes."</p>
<p>The same idea scales up to your whole app. The tool for it is <code>requestAnimationFrame</code>.</p>
<p><code>requestAnimationFrame</code> runs your callback once, right before the browser paints the next frame — after the current JavaScript has finished.</p>
<p>So you can split a frame into two phases:</p>
<pre><code class="language-plaintext">during the event:    read everything you need — layout is clean
↓
requestAnimationFrame
↓
just before paint:   write everything — layout dirties, then flushes once
</code></pre>
<p>Reads now. Writes later. Never interleaved.</p>
<p>Here it is on a scroll-to-top after some change:</p>
<pre><code class="language-ts">// before — a synchronous write, in the middle of everything else
container.scrollTo({ top: 0 });

// after — deferred to just before the next paint
requestAnimationFrame(() =&gt; {
  container.scrollTo({ top: 0 });
});
</code></pre>
<p>Wrapping the write in <code>requestAnimationFrame</code> lifts it out of the read phase. If you had already read some geometry earlier in the same event, this stops the write from forcing a reflow in the middle of it.</p>
<p>This is the seed of what a library like FastDOM does: a <code>measure()</code> queue and a <code>mutate()</code> queue, both flushed in one rAF, reads always before writes.</p>
<p>But be honest about what you changed.</p>
<p>The write now happens one frame later.</p>
<p>You might think that is free.</p>
<p>It is not always.</p>
<pre><code class="language-ts">requestAnimationFrame(() =&gt; {
  container.scrollTo({ top: 0 });
});
const top = container.scrollTop; // reads the OLD position — the scroll hasn't run yet
</code></pre>
<p>Any code right after — anything that assumes the scroll already happened — now sees the old value. If something depends on "we are at the top now," either keep the write synchronous, or move that dependent code into the same <code>requestAnimationFrame</code> callback.</p>
<p>Deferring a write buys you clean read/write ordering. It costs you a frame of "not yet." Know which one you need.</p>
<h2>The sibling problem: animating the wrong property</h2>
<p>Layout thrashing is <em>reads</em> forcing layout. There's a mirror problem: <em>writes</em> that force layout every frame.</p>
<p>Animate an element's position with <code>left</code> and <code>top</code>:</p>
<pre><code class="language-js">box.style.left = x + "px"; // layout property
box.style.top = y + "px";
</code></pre>
<p><code>left</code> and <code>top</code> are layout properties. Change them and the browser re-runs Layout — then Paint, then Composite — every frame of the animation. On many elements, it starts missing frames.</p>
<p>Animate with <code>transform</code> instead:</p>
<pre><code class="language-js">box.style.transform = `translate(${x}px, ${y}px)`;
</code></pre>
<p><code>transform</code> touches neither Layout nor Paint. It goes straight to Composite — the GPU stage, off the main thread. The browser just moves a layer it already painted. Cheap.</p>
<p>Two properties. The same motion on screen. One re-runs the whole pipeline; the other skips to the last stage.</p>
<p>This is also why <code>transition: all</code> is a quiet trap. It opts <em>every</em> animatable property into transitions — including the layout ones. You meant to fade a color. You also signed up to animate <code>margin</code>, and now the browser runs Layout through the entire transition.</p>
<p>Name the properties you actually animate. Never <code>all</code>.</p>
<h2>The twist: a smooth FPS counter can be lying</h2>
<p>Here is where the mental model earns its keep.</p>
<p>I built a small lab for all of this — a grid of boxes, a checkbox per anti-pattern, and a live FPS meter. Tick "forced reflow loop" and the meter craters from 120 to 4. You can watch the frame time explode from 8 ms to 235 ms. The thrash is right there in the number.</p>
<p>Then I added a "paint bomb": heavy blurred shadows, re-painted every frame, across a thousand boxes. Genuinely expensive.</p>
<p>I ticked it, expecting the meter to crater again.</p>
<p>It didn't move. 120 FPS. 8.3 ms. Flat.</p>
<p>The animation was visibly stuttering — and the number said everything was fine.</p>
<p>Go back to the pipeline. Where does Paint run? The main thread <em>records</em> the paint, but the heavy part — rasterizing all that blur — happens on the compositor thread and the GPU. My FPS meter measures the main thread, by timing <code>requestAnimationFrame</code>. It is blind to compositor work by construction.</p>
<p>You might think a green FPS counter means a fast page.</p>
<p>It means a free <em>main thread</em>. That is not the same thing.</p>
<p>Layout thrashing shows up there, because Layout is on the main thread. Paint jank doesn't, because raster isn't. To see paint cost you need a different instrument — <a href="https://developer.chrome.com/docs/devtools/rendering/performance#frame-rendering-stats">DevTools' rendering stats</a> — not a rAF counter.</p>
<p>The blind spot is the lesson. Always know which thread your metric is watching.</p>
<h2>When this isn't your problem</h2>
<p>Reordering reads and writes is the fix for thrashing. But be honest about the ceiling.</p>
<p>If you're thrashing over ten thousand DOM nodes, the real problem isn't the ordering — it's that you have ten thousand nodes. The fix is to render fewer of them (virtualization), not to reflow all of them more politely.</p>
<p>And not every janky page is thrashing. If your reads and writes are already batched and it still stutters, you might be paint-bound, or running too much JavaScript, or animating the wrong property. Layout thrashing is one specific cause with one specific fix. Reach for it when you see reads and writes interleaved — not as a cure-all.</p>
<h2>The whole thing, in three lines</h2>
<p>The browser batches layout, so it computes geometry once per frame.</p>
<p>A read after a write forces it to compute <em>now</em>, in the middle of your loop.</p>
<p>Move every read above every write, and a thousand reflows collapse into one.</p>
<h2>Cheat sheet: how layout thrashing happens</h2>
<p>It always takes two ingredients in the same frame:</p>
<pre><code class="language-plaintext">a write that dirties layout   +   a geometry read that needs it clean= the browser recomputes layout right now, to answer the read
</code></pre>
<p>Everything below is a variation on that one recipe.</p>
<p><strong>The reads that force layout</strong> (asking any of these while layout is dirty triggers a reflow):</p>
<table>
<thead>
<tr>
<th>Read</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><code>offsetTop / offsetLeft / offsetWidth / offsetHeight</code></td>
<td>the classic</td>
</tr>
<tr>
<td><code>clientTop / clientWidth / clientHeight</code></td>
<td></td>
</tr>
<tr>
<td><code>scrollTop / scrollWidth / scrollHeight</code></td>
<td></td>
</tr>
<tr>
<td><code>getBoundingClientRect()</code></td>
<td>forces layout on the <strong>call</strong>, not on reading the returned rect</td>
</tr>
<tr>
<td><code>window.innerWidth / innerHeight</code></td>
<td>yes, even these</td>
</tr>
<tr>
<td><code>getComputedStyle(el).height</code> (any layout value)</td>
<td>looks passive, forces layout anyway</td>
</tr>
</tbody></table>
<p><strong>The shapes it takes, and the fix for each:</strong></p>
<table>
<thead>
<tr>
<th>How it happens</th>
<th>Why it thrashes</th>
<th>The fix</th>
</tr>
</thead>
<tbody><tr>
<td>Read then write, interleaved in a loop (<code>offsetHeight</code> → <code>style.height</code> → next)</td>
<td>Each read forces a full layout — N boxes, N reflows</td>
<td>Read all first, then write all</td>
</tr>
<tr>
<td><code>getBoundingClientRect()</code> called once per element in a loop</td>
<td>Every call forces layout again</td>
<td>Call once, cache the <code>DOMRect</code>, read <code>.top/.height</code> off the snapshot</td>
</tr>
<tr>
<td><code>getComputedStyle(el)</code> sprinkled through a render loop</td>
<td>It's not a free CSS lookup — it flushes layout like <code>offsetHeight</code></td>
<td>Cache the value; don't re-read per element</td>
</tr>
<tr>
<td>A scroll handler measuring every box each tick</td>
<td>Scroll fires many times a second; each tick = N reflows</td>
<td><code>IntersectionObserver</code>, or throttle to one read per frame</td>
</tr>
<tr>
<td>Reads look batched, but a <code>setState</code>/deferred write commits between events</td>
<td>The write and the read live in different frames — thrash across time, invisible in any single function</td>
<td><code>IntersectionObserver</code> — let the browser report crossings; stop asking</td>
</tr>
<tr>
<td>Animating <code>left</code> / <code>top</code></td>
<td>Layout property → re-runs Layout → Paint → Composite every frame</td>
<td>Animate <code>transform: translate(...)</code> — straight to Composite, off the main thread</td>
</tr>
<tr>
<td><code>transition: all</code></td>
<td>Opts every property in, including layout ones like <code>margin</code></td>
<td>Name only the properties you animate</td>
</tr>
</tbody></table>
<p><strong>When it's <em>not</em> thrashing (don't over-correct):</strong></p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Verdict</th>
</tr>
</thead>
<tbody><tr>
<td>One write→read→write on a single element (auto-grow textarea)</td>
<td>Fine — one flush, once per keystroke. Scale is the whole difference.</td>
</tr>
<tr>
<td>Thrashing over 10,000 DOM nodes</td>
<td>The problem is the node count — virtualize, don't reflow them more politely</td>
</tr>
<tr>
<td>Green FPS meter, but the page still stutters</td>
<td>You may be paint-bound. A rAF FPS counter watches the <strong>main thread</strong>; raster runs on the compositor. Check DevTools rendering stats.</td>
</tr>
</tbody></table>
<p>The one rule under all of it: <strong>reads now, writes later — never interleaved.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Give your AI agents something to remember]]></title><description><![CDATA[Running gbrain fully local
I wanted a personal knowledge base that could actually answer questions across my notes, with citations, and I wanted it to run entirely on my laptop. No OpenAI key. No Anth]]></description><link>https://featuringcode.com/give-your-ai-agents-something-to-remember</link><guid isPermaLink="true">https://featuringcode.com/give-your-ai-agents-something-to-remember</guid><category><![CDATA[gbrain]]></category><category><![CDATA[ai-agent]]></category><category><![CDATA[agent-brain]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Wed, 01 Jul 2026 20:21:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/9126ee8b-bcfe-4011-81cf-4209dad4a78c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Running gbrain fully local</h1>
<p>I wanted a personal knowledge base that could actually answer questions across my notes, with citations, and I wanted it to run entirely on my laptop. No OpenAI key. No Anthropic key. No cloud database. Nothing about what I write leaving the machine.</p>
<p>That is what this walkthrough builds: <a href="https://github.com/garrytan/gbrain">gbrain</a> wired so that a local model does the thinking, a local model does the remembering, and a file on disk does the storing.</p>
<p>Along the way I hit one trap that every "just use Ollama" guide gets wrong. I will show you the trap, because understanding why it fails is the fastest way to understand how the whole thing fits together.</p>
<h2>First: what is gbrain, and why bother</h2>
<p>gbrain is a searchable memory that answers questions about your own notes — and cites the note it used.</p>
<p>Not a note app. Not a generic chatbot. Something in the gap between them.</p>
<p>You feed it decisions, people, meetings, whole documents. Later you ask a question, and it finds the relevant notes and writes an answer built from <em>your</em> pages.</p>
<p>Why that gap matters: the two tools you already have both fail, in opposite ways.</p>
<p>Search gives you ten links and makes you do the reading.</p>
<p>A chatbot writes a confident answer, but it has never seen your notes — ask it "what did <em>we</em> decide about pricing?" and it guesses.</p>
<p>gbrain reads your actual pages and answers from them:</p>
<pre><code class="language-plaintext">you:     "Why was the base tier price raised?"
gbrain:  "Raised from $19 to $29 because support costs per seat grew and the
          $19 tier was unprofitable below 50 seats [pricing-decision]."
</code></pre>
<p>The answer is built from your note, and it names the page, so you can trust it. And when it does not know, it says so — an honest "I don't have that" instead of a confident wrong answer.</p>
<p>So the reasons to run it: your context stops evaporating, you can ask across everything you have ever written at once, and — wired into a coding agent — it gives that agent a memory so it stops re-asking what it could look up. In this build, all of that stays on your laptop.</p>
<p>One caveat worth saying up front: an empty brain answers nothing, so day one feels broken. It earns its keep once capturing becomes a habit.</p>
<p>Everything below was built and verified on an Apple M5 with 32 GB. One running example carries through the whole post: a folder with two notes in it.</p>
<pre><code class="language-plaintext">pricing-decision.md   "raise the base tier from $19 to $29 in July..."
hiring.md             "freeze backend hiring until the pricing change lands..."
</code></pre>
<p>By the end, I can ask "why was the price raised, and what hiring change followed?" and get a cited answer, with zero network calls.</p>
<h2>The shape of the machine</h2>
<p>Before any commands, here is the whole system on one screen. Follow the arrows.</p>
<pre><code class="language-plaintext">your notes (markdown)
      ↓
Ollama · nomic-embed-text   turns each note into 768 numbers      (embeddings)
      ↓
PGLite · a file on disk     stores the notes and the numbers      (memory)
      ↓   ← your question comes in here, gets turned into numbers too
find the closest notes by comparing numbers
      ↓
Ollama · qwen2.5:14b        reads those notes, writes the answer   (generation)
</code></pre>
<p>Three actors. Ollama runs the models. PGLite holds everything. gbrain is the CLI that moves data between them.</p>
<p>Keep that picture. Every section below is just one arrow in it.</p>
<h2>Two models, because there are two different jobs</h2>
<p>The first thing that trips people up: you need two models, not one, and they are not interchangeable.</p>
<p><strong>One model turns text into numbers.</strong></p>
<p>You hand it a sentence. It hands back a list of 768 numbers.</p>
<p>That list is called an embedding. Similar sentences produce similar lists. That is the entire reason search will work later.</p>
<p><strong>The other model turns notes into an answer.</strong></p>
<p>You hand it your question plus the notes that matched. It writes prose back.</p>
<p>An embedding model cannot write you a paragraph. A chat model <em>could</em> produce an embedding, but it is huge and slow for a job that runs on every single note. So gbrain uses one of each:</p>
<pre><code class="language-bash">ollama pull nomic-embed-text     # embeddings · 768 dims · ~274 MB
ollama pull qwen2.5:14b          # generation · ~9 GB
</code></pre>
<p><code>ollama pull</code> downloads the weights to <code>~/.ollama/models</code> once. After that they load from local disk, and nothing about them touches the network again.</p>
<p>An embedding is not an AI conversation. It is not something the chat model does. It is a separate, tiny, constant job — and it needs its own small model.</p>
<h2>Ingestion: how a note becomes a memory</h2>
<p>Here is the first arrow. It runs entirely on your machine.</p>
<pre><code class="language-bash">gbrain import ~/notes
# Found 2 markdown files
# 2 pages imported, 2 chunks created
</code></pre>
<p>Walk through what "2 chunks created" actually means.</p>
<p>gbrain reads each file, splits it into chunks, and sends each chunk to Ollama to be embedded. Ollama returns 768 numbers per chunk. gbrain writes the chunk text <em>and</em> its 768 numbers into the database.</p>
<p>The naive version skips the chunking and embeds the whole file as one blob. That breaks the moment a file covers two topics: the pricing note and a paragraph about, say, office snacks would share one averaged vector, and neither would be findable on its own. Splitting first keeps each idea searchable.</p>
<p>Where does the database live? In a single folder:</p>
<pre><code class="language-plaintext">~/.gbrain/brain.pglite
</code></pre>
<p>That is PGLite. It is Postgres, compiled to run <em>inside</em> gbrain instead of as a separate server you install and start. No port. No password. No <code>postgres</code> process. You could copy that folder to another Mac and it would just work.</p>
<p>So after ingestion:</p>
<ul>
<li><p><strong>Where it ran:</strong> your machine. gbrain locally, Ollama locally.</p>
</li>
<li><p><strong>What got stored:</strong> the note text and its 768-number vector, in the PGLite file.</p>
</li>
<li><p><strong>What was computed once:</strong> the embeddings. They are not recomputed when you search.</p>
</li>
<li><p><strong>What it hands to the next step:</strong> a database full of (text, vector) rows, ready to be matched.</p>
</li>
</ul>
<p>This is worth saying plainly: embedding happens at import time, once. Your question, later, is the only thing embedded fresh.</p>
<h2>Asking: how a question finds its answer</h2>
<p>Now the interesting arrow.</p>
<pre><code class="language-bash">gbrain think "What did we decide about pricing and why?"
</code></pre>
<p>Four things happen, in order:</p>
<pre><code class="language-plaintext">your question
   ↓  Ollama embeds it into 768 numbers        (fresh, every time)
   ↓  PGLite finds the note-vectors closest to it
   ↓  gbrain hands those notes + your question to qwen2.5:14b
   ↓  qwen writes an answer that cites the notes
</code></pre>
<p>The search step is pure math. "Closest" means the note vectors whose 768 numbers point in nearly the same direction as your question's 768 numbers. No AI runs during the search — it is comparing lists of numbers the embedding model already produced.</p>
<p>Only the last step is generation. The model does not know your notes; it is <em>handed</em> them, in the prompt, and told to answer using only those. That is why the answer can cite <code>[pricing-decision]</code>: the note was in front of it.</p>
<p>Here is the real answer it gave:</p>
<blockquote>
<p>We decided to raise the base tier from $19 to $29/month starting July because support costs per seat increased and the $19 tier was unprofitable below 50 seats <code>[pricing-decision]</code>. Existing customers are grandfathered at $19 for 12 months.</p>
</blockquote>
<p>Retrieval prepared the knowledge. Search found the knowledge. The model explained the knowledge. Now the only question left is: how do we make that last model local?</p>
<h2>The trap: <code>ollama:</code> does not work for chat</h2>
<p>This is the part every shortcut guide gets wrong, so slow down here.</p>
<p>Embeddings used <code>ollama:nomic-embed-text</code>. The obvious next move is to point generation at Ollama the same way:</p>
<pre><code class="language-bash">gbrain config set chat_model ollama:qwen2.5:14b
gbrain think "Why was the price raised?"
</code></pre>
<p>And you get this:</p>
<pre><code class="language-plaintext">(no LLM available — set ANTHROPIC_API_KEY or pass `client`)
Model: ollama:qwen2.5:14b | Pages: 2 | Warnings: NO_ANTHROPIC_API_KEY
</code></pre>
<p>Look closely, because the output is almost taunting you. It <em>knows</em> the model is <code>ollama:qwen2.5:14b</code>. It found 2 pages. Then it refused to write anything and asked for a cloud key.</p>
<p>The retrieval ran locally. The synthesis quit.</p>
<h3>Why it quits: recipes and touchpoints</h3>
<p>gbrain reaches every provider through a <strong>recipe</strong> — a small description of one provider: its URL, how it authenticates, and which jobs it can do.</p>
<p>Those jobs are called <strong>touchpoints</strong>. There are three: <code>embedding</code>, <code>chat</code>, <code>reranker</code>.</p>
<p>Here is the actual Ollama recipe shipped inside gbrain, trimmed to the point:</p>
<pre><code class="language-plaintext">ollama recipe
  touchpoints:
    embedding: ✓   (nomic-embed-text, mxbai-embed-large, all-minilm)
    chat:      ✗   (not declared)
</code></pre>
<p>That is the whole explanation. The Ollama recipe declares <code>embedding</code> and nothing else. When you ask gbrain to <em>chat</em> with Ollama, it checks the recipe, finds no <code>chat</code> touchpoint, and silently falls back to its built-in default — a cloud model that needs a key.</p>
<p>This is not your fault, and it is not a bug you can config your way out of. It is a known, open gap: gbrain's own <code>COMMUNITY_IDEAS.md</code> lists "local-first chat parity" and "litellm proxy unusable for chat" as open issues. Today, Ollama is wired for embeddings only.</p>
<h3>The fix: borrow a recipe that has chat</h3>
<p>We need a recipe that <em>does</em> declare <code>chat</code> and that lets us change its URL to point at Ollama. Of the chat-capable recipes, the OpenAI-compatible ones can be repointed with an environment variable. <code>openrouter</code> is one.</p>
<p>The move:</p>
<pre><code class="language-plaintext">openrouter recipe (has chat ✓, speaks the OpenAI API)
        ↓  change its base URL to...
http://localhost:11434/v1   (Ollama's OpenAI-compatible endpoint)
</code></pre>
<p>Ollama already speaks the OpenAI API. So gbrain believes it is calling OpenRouter, and the request actually lands on Ollama, on your laptop.</p>
<pre><code class="language-bash">export OPENROUTER_BASE_URL=http://localhost:11434/v1   # send openrouter calls to Ollama
export OPENROUTER_API_KEY=ollama                       # dummy; Ollama ignores auth
gbrain config set chat_model     openrouter:qwen2.5:14b
gbrain config set models.default openrouter:qwen2.5:14b
</code></pre>
<p>The dummy key exists only because gbrain checks that <em>a</em> key is present before it will try. Ollama throws it away.</p>
<p>Two things I learned the hard way:</p>
<ul>
<li><p><strong>The base URL must be an environment variable.</strong> <code>gbrain config set provider_base_urls.openrouter ...</code> looks like it works — <code>config get</code> even reads it back — but the gateway never reads that value. It reads <code>OPENROUTER_BASE_URL</code> from the environment. So it lives in a <code>env.sh</code> you source, not in the brain config.</p>
</li>
<li><p><strong>A cold LLM can starve the query embedding.</strong> The first question after a restart timed out its embedding at 6 seconds (Ollama was busy loading the 9 GB model) and returned an empty answer. Two exports fix it:</p>
<pre><code class="language-bash">export GBRAIN_QUERY_EMBED_TIMEOUT_MS=30000   # let the embed wait out the load
export OLLAMA_KEEP_ALIVE=30m                  # keep the model warm
</code></pre>
</li>
</ul>
<h2>Proof: watch the traffic, do not trust the claim</h2>
<p>"Fully local" is a claim. The only way to trust it is to watch every model call and confirm it landed on <code>localhost</code>.</p>
<p>So ask a real question and tail Ollama's log at the same time:</p>
<pre><code class="language-bash">gbrain think "What did we decide about pricing and why?"
</code></pre>
<p>The answer comes back cited, <code>Model: openrouter:qwen2.5:14b | Citations: 1</code>. And in Ollama's own log, at that exact moment:</p>
<pre><code class="language-plaintext">200   25ms    POST  /v1/embeddings         ← the question became a vector
200   10.6s   POST  /v1/chat/completions   ← qwen wrote the answer
</code></pre>
<p>Two requests. Both to <code>127.0.0.1</code>. No Anthropic. No OpenAI. No key that reaches the internet. That log is the whole point of the exercise — it is the difference between "I think it is local" and "I watched it be local."</p>
<h2>Making it survive a reboot</h2>
<p>Everything so far works <em>in this shell</em>. Close the terminal and two things evaporate: the Ollama daemon (I started it by hand) and the four environment variables (they only live in a file called <code>env.sh</code>). A real install has to outlive the session. Two steps do that, and it is worth knowing what each one actually produces.</p>
<p><strong>Step 1 — turn Ollama into a background service.</strong></p>
<pre><code class="language-bash">brew services start ollama
</code></pre>
<p>This does not just run Ollama. It writes a launchd agent — a small file at <code>~/Library/LaunchAgents/homebrew.mxcl.ollama.plist</code> — and hands it to macOS. launchd is the thing that starts programs at login and restarts them if they die.</p>
<p>So what this <em>produces</em> is a daemon that is always there: it comes up when you log in, relaunches itself if it crashes, and writes its log to <code>/opt/homebrew/var/log/ollama.log</code>. You never type <code>ollama serve</code> again. <code>brew services list</code> shows it as <code>started</code>.</p>
<pre><code class="language-plaintext">ollama   started   ~/Library/LaunchAgents/homebrew.mxcl.ollama.plist
</code></pre>
<p><strong>Step 2 — load the environment in every shell.</strong></p>
<p>The four <code>export</code>s live in <code>env.sh</code>. To make every new terminal pick them up, add one line to <code>~/.zshrc</code> (the file your shell runs on startup):</p>
<pre><code class="language-bash">source ~/Projects/gbrain-ollama/env.sh
</code></pre>
<p>What this <em>produces</em> is that <code>OPENROUTER_BASE_URL</code>, <code>OPENROUTER_API_KEY</code>, and the two timeout/keep-alive vars are set in every interactive shell you open. gbrain's CLI reads them from the environment, so <code>gbrain think</code> finds the local route without you sourcing anything by hand.</p>
<p>The test that proves both worked: open a brand-new terminal, type nothing but <code>gbrain think "..."</code>, and watch <code>/opt/homebrew/var/log/ollama.log</code>. You should see two <code>127.0.0.1</code> requests and a cited answer.</p>
<p>One honest caveat. The launchd daemon starts with a clean environment — it does <em>not</em> read <code>env.sh</code> — so <code>OLLAMA_KEEP_ALIVE</code> from your shell does not reach it, and it falls back to unloading an idle model after a few minutes. That is exactly why <code>GBRAIN_QUERY_EMBED_TIMEOUT_MS=30000</code> matters: it lets the query embedding wait out the occasional cold reload instead of timing out.</p>
<h2>Now actually use it: locally and globally</h2>
<p>You have a running brain. Here is how you talk to it.</p>
<p>Everything you do is one of two things: putting knowledge <em>in</em>, or getting it <em>out</em>.</p>
<pre><code class="language-plaintext">   PUT IN                          GET OUT
   capture / import / put / sync   search / query / think
        ↓                                    ↑
              ~/.gbrain/brain.pglite
</code></pre>
<p>And one rule governs all of it: <strong>gbrain only saves what it is told to save.</strong> Nothing is captured automatically — not your terminal, not your chats. Text enters the brain when a write command runs, and only then.</p>
<h3>Locally, from the terminal</h3>
<p>Putting things in:</p>
<pre><code class="language-bash">gbrain capture "Decided to run gbrain fully local: Ollama + PGLite, no cloud."
gbrain import ~/notes/                 # a folder of markdown you already have
gbrain sync --repo ~/code/myproject    # a whole git repo
</code></pre>
<p>Getting things out — and the three commands are not the same:</p>
<pre><code class="language-bash">gbrain search "pricing"                    # ranked pages, keyword — fast, no LLM
gbrain query  "why was the price raised?"  # hybrid (meaning + keywords), ranked pages
gbrain think  "why was the price raised?"  # a written, cited answer — uses the LLM
</code></pre>
<p>The practical difference, which I checked by watching Ollama's log: <code>search</code> and <code>query</code> only ever hit <code>/v1/embeddings</code> — they hand you matching pages and need no chat model. <code>think</code> is the only one that calls qwen to <em>write</em> an answer. So most of your day-to-day retrieval does not even touch the generation model.</p>
<h3>Globally, from your coding agent</h3>
<p>This is where it stops being a CLI and becomes a memory your agent shares. Wire it in over MCP, globally:</p>
<pre><code class="language-bash">claude mcp add gbrain -s user \
  -e OPENROUTER_BASE_URL=http://localhost:11434/v1 \
  -e OPENROUTER_API_KEY=ollama \
  -e GBRAIN_QUERY_EMBED_TIMEOUT_MS=30000 \
  -- gbrain serve
</code></pre>
<p><code>-s user</code> is what makes it global. It writes the registration to <code>~/.claude.json</code>, so the brain is available in <em>every</em> project on this machine — and because that file belongs to your Mac user, not your Anthropic login, it stays available no matter which Claude account you sign in with. Drop <code>-s user</code> and it registers for the current project only. (If the agent later reports it can't find <code>gbrain</code>, an MCP subprocess may not have your shell's PATH — swap <code>gbrain serve</code> for the absolute path from <code>command -v gbrain</code>.)</p>
<p>Those <code>-e</code> flags are not optional on a local setup, and the reason ties back to everything above. A coding agent launches <code>gbrain serve</code> with a clean environment — it does not read your <code>~/.zshrc</code>. Skip the flags and brain <em>search</em> still works (embeddings need no env), but <em>synthesis</em> over MCP falls back to "no LLM available." The flags hand the local route straight to the subprocess.</p>
<p><em><strong>Then teach the agent to use it</strong></em>. This is the step people skip — and without it the tools just sit there. The agent can see them, but it will not reach for them on its own; you would have to say "search my brain" every single time. The fix is a few lines in a <code>CLAUDE.md</code> file, which Claude Code reads at the start of every session.</p>
<p>Where you put the file decides how far it reaches:</p>
<ul>
<li><p><code>~/.claude/CLAUDE.md</code> — your user file, loaded in <em>every</em> project. Best for a personal brain, and it matches a user-scope MCP.</p>
</li>
<li><p><code>./CLAUDE.md</code> in a repo — scoped to just that project.</p>
</li>
</ul>
<p>Create the file if it does not exist, and paste in the protocol. Here is exactly how it looks:</p>
<pre><code class="language-markdown">## gbrain — brain-first protocol
You have a personal knowledge brain connected over MCP (tools: search, query, put_page). Use it:
1. Search first. Before answering about people, companies, decisions, projects, or past context, call search/query against the brain. If it has the answer, use it and cite the page — don't ask what you can retrieve.
2. Write back. When I make a decision or mention a new person/company/idea worth keeping, save it with put_page.
3. Cite. Name the page you used.
</code></pre>
<p>That is the whole thing — one markdown heading and three rules. On the next session start, Claude reads it and begins reaching for the brain on its own: searching before it asks you, writing decisions back as you work. (You can go further and add note-quality rules to the same file — that is the "Teaching Claude to write good notes" section below.) If you use <a href="https://github.com/garrytan/gstack">gstack</a>, two skills automate the whole thing: <code>/setup-gbrain</code> does the wire-up and writes that protocol for you, and <code>/sync-gbrain</code> indexes a code repo so <code>gbrain search</code> works semantically across the codebase.</p>
<p>And to close the loop on the obvious worry: connecting your agent does <em>not</em> record the conversation. The agent saves something only when it calls <code>put_page</code> — decisions and new ideas, nothing else. You stay in control of what your brain remembers.</p>
<h2>Is it fast enough for coding?</h2>
<p>The honest first reaction to a local setup is: I asked a question and waited ten seconds. Won't that wreck my flow?</p>
<p>It won't, and the reason is in the numbers. Measured on this brain:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>Wall time</th>
<th>What Ollama did</th>
</tr>
</thead>
<tbody><tr>
<td><code>gbrain search "..."</code></td>
<td>1.1s</td>
<td>embeddings only (3–5ms)</td>
</tr>
<tr>
<td><code>gbrain query "..."</code></td>
<td>0.6s</td>
<td>embeddings only (3–7ms)</td>
</tr>
<tr>
<td><code>gbrain think "..."</code></td>
<td>8.6s</td>
<td>embeddings (2ms) + <strong>chat ~5s</strong></td>
</tr>
</tbody></table>
<p>Read that with one question: where does the time go?</p>
<p>Not the embedding. That is 3 milliseconds.</p>
<p>Not the database. That is instant.</p>
<p>The 8.6 seconds is entirely qwen2.5:14b <em>writing</em> the answer, a token at a time, on your laptop. A 14B local model is slower than a cloud one. That is the price of nothing leaving the machine.</p>
<p>But look at <em>which</em> command pays it. Only <code>think</code>.</p>
<p>And your coding agent does not call <code>think</code>.</p>
<p>When Claude uses the brain while you code, it calls <code>search</code> and <code>query</code> — it <em>fetches</em> your notes and reasons over them with its own fast model. It does not ask the local 14B to write prose. Fetching is sub-second.</p>
<p>It is even faster than the table looks. Most of that 0.6–1.1s is the <code>gbrain</code> CLI booting a fresh process. Over MCP, <code>gbrain serve</code> stays resident, so that cost is paid once at session start, not per call. The agent's real lookups are an embedding plus a database query — tens of milliseconds.</p>
<p>So the picture during development: a brain lookup returns in well under a second, a rounding error next to the agent's own thinking time. The eight-second wait only happens when <em>you</em> deliberately ask the brain to compose an answer with <code>think</code> — not something the agent's loop does.</p>
<p>If you want <code>think</code> snappier too, three levers:</p>
<ul>
<li><p><strong>A smaller chat model.</strong> <code>gbrain config set chat_model openrouter:qwen2.5:7b</code> (after <code>ollama pull qwen2.5:7b</code>) roughly halves generation time, for slightly weaker answers.</p>
</li>
<li><p><strong>Keep it warm.</strong> The cold runs earlier were 17–21s because qwen had to load into memory first; warm runs are 7–9s. An idle model unloads after a few minutes, so the first <code>think</code> after a break pays that reload. It never touches <code>search</code>/<code>query</code>.</p>
</li>
<li><p><strong>Cloud chat for</strong> <code>think</code> <strong>only.</strong> Point just the chat model at a real key if you want fast, high-quality synthesis and don't mind that one step leaving the machine. Retrieval stays fully local.</p>
</li>
</ul>
<h2>Want a better brain, for free?</h2>
<p>qwen2.5:14b is fine, but it is not Claude. So the obvious wish: can I just use the good model I am already using in Claude Code, without a separate API key?</p>
<p>Directly, no — for two concrete reasons.</p>
<p>gbrain never asks the host to think for it. Even the <code>think</code> <em>tool</em>, called over MCP, runs gbrain's own configured model, not Claude Code's.</p>
<p>And a Claude subscription is not a reusable API key. gbrain's <code>anthropic</code> recipe wants a standalone <code>ANTHROPIC_API_KEY</code>, which is pay-per-token — a separate, paid thing, not the login you already have.</p>
<p>But the outcome you want <em>is</em> free, and it is what the MCP wiring is actually for. You flip who does the writing.</p>
<p><code>gbrain think</code> makes <em>gbrain</em> synthesize, on the slow local model. Instead, let Claude Code be the reasoning layer:</p>
<pre><code class="language-plaintext">gbrain retrieves   — search / query, local, fast, free
Claude Code writes — with the frontier model you already use
</code></pre>
<p>So inside Claude Code you say "search my brain for X and explain it." Claude calls the fast <code>search</code> tool, pulls your pages into its own context, and writes the answer itself — Claude quality, no extra key, no local 14B. That is "use the Claude Code model over my brain, for free," and the brain-first protocol already nudges the agent toward <code>search</code>/<code>query</code> instead of the local <code>think</code>.</p>
<p>The honest caveat: those retrieved snippets go into Claude Code's context, so that one step reaches Anthropic. No new key, the same trust boundary you already accepted by using Claude Code — but not "nothing leaves the machine." Storage and retrieval stay local.</p>
<p>And if you want gbrain's <em>own</em> <code>think</code> better, with no host in the loop:</p>
<ul>
<li><p><strong>Better, still fully local, still free</strong> — a bigger local model. <code>gpt-oss:20b</code> (<del>13GB) sits comfortably on 32GB and beats 14B; <code>qwen2.5:32b</code> (</del>20GB) is higher quality still, but tight on RAM and slower.</p>
<pre><code class="language-bash">ollama pull gpt-oss:20b
gbrain config set chat_model     openrouter:gpt-oss:20b
gbrain config set models.default openrouter:gpt-oss:20b
</code></pre>
</li>
<li><p><strong>Better, free, but not local</strong> — a free cloud tier. Google Gemini (free key from AI Studio) or Groq (free tier); gbrain ships <code>google</code> and <code>groq</code> recipes. Higher quality than qwen, free within rate limits, but your notes go to that provider. Grab a key and set <code>chat_model</code> to <code>google:&lt;model&gt;</code> or <code>groq:&lt;model&gt;</code> (exact names via <code>gbrain providers env google</code>).</p>
</li>
</ul>
<p>For coding, the best option is not in that last list at all — it is the one from a few paragraphs up: <strong>let Claude Code do the writing.</strong> You are already sitting inside the strong model, so let gbrain fetch and let Claude synthesize. Best quality, no extra key. That is what connecting the brain was for in the first place — the brain is Claude's memory to look things up in, not a second, weaker model trying to answer in its place.</p>
<h2>How Claude actually talks to your brain</h2>
<p>This is the part that surprised me, and it changes how you use the whole thing.</p>
<p>You do not run <code>gbrain think</code>.</p>
<p>You do not even type the word gbrain.</p>
<p>You ask Claude a question in plain English — "when's my birthday?" — and it answers from your notes. No command. So how does that work?</p>
<h3>Claude learns your brain exists, at startup</h3>
<p>When you ran <code>claude mcp add gbrain … -- gbrain serve</code>, you did not just save a line of config. You told Claude Code: "there is a tool server here — launch it and ask it what it can do."</p>
<p>So every time Claude Code starts, it:</p>
<ol>
<li><p>Spawns <code>gbrain serve</code> as a background subprocess.</p>
</li>
<li><p>Asks it, over MCP, "what tools do you offer?"</p>
</li>
<li><p>Gets back a list — 92 of them: <code>search</code>, <code>query</code>, <code>put_page</code>, <code>get_page</code>, and so on, each with a one-line description of what it does.</p>
</li>
</ol>
<p>Now your brain is just <em>there</em>, in Claude's toolbox, the same way it knows it can read a file or run a command.</p>
<h3>What happens when you ask</h3>
<p>Watch the birthday question flow through:</p>
<pre><code class="language-plaintext">you (in Claude Code):  "when's my birthday?"
      ↓
Claude decides a brain lookup would help, and calls a tool:
      search({ query: "birthday" })              ← Claude → gbrain
      ↓
gbrain runs it locally: embed the query (Ollama, ~3ms), search PGLite
      ↓
      returns the page text: "on 20th of june… born 1989"   ← gbrain → Claude
      ↓
Claude reads that in its own context and writes:
      "June 20th; born 1989, so you'll turn 37 — in 14 days."
</code></pre>
<p>Two things to notice.</p>
<p><strong>Claude chose to call the tool.</strong> You did not tell it to. It saw a question about you, remembered it has a brain tool, and reached for it — partly because the tool's description says it searches your knowledge, and partly because the brain-first protocol you pasted into <code>CLAUDE.md</code> tells it to look there first.</p>
<p><strong>gbrain never wrote a sentence.</strong> It embedded, searched, and handed back a raw page. The reasoning — the age math, "in 14 days" — is Claude's. gbrain was the memory; Claude was the mind.</p>
<h3>Why it works: MCP is just a tool protocol</h3>
<p>There is no magic here. MCP (Model Context Protocol) is a standard way for a program to expose tools to an LLM. gbrain speaks it; Claude Code speaks it. When they connect, Claude gets a menu of gbrain's tools with descriptions, and from then on it can call any of them, read the result, and continue — the same tool-use loop it runs for reading files or executing shell commands.</p>
<p>So "talking to your brain" is not a special mode. Your brain simply became one more tool Claude picks up when a question calls for it.</p>
<h3>The practical upshot</h3>
<p>Stop thinking in <code>gbrain</code> commands. Once it is wired in:</p>
<ul>
<li><p>Ask Claude questions in plain language; it retrieves and reasons for you.</p>
</li>
<li><p>Tell it to remember things — "note that we decided X" — and it calls <code>put_page</code>.</p>
</li>
<li><p>The terminal <code>gbrain</code> commands are still there for when Claude is not in the loop.</p>
</li>
</ul>
<p>You built a filing cabinet and handed Claude the key.</p>
<h2>What actually makes the answers better</h2>
<p>Once Claude is the one writing, the chat model stops being the lever. Two other things take over, and it is worth being clear about which.</p>
<p><strong>Your notes are the first lever, and the biggest by far.</strong></p>
<p>Retrieval can only surface what you actually wrote down. A thin brain gives thin answers, no matter how strong the models are. So the highest-return habit is simply capturing — a decision here, a fact there — until the pages exist to be found. A fuller brain beats a fancier model every time.</p>
<p><strong>Retrieval quality is the second lever — and that is the embedding model, not the chat model.</strong></p>
<p>Keep the split in mind: <code>search</code>/<code>query</code> find pages with <code>nomic-embed-text</code>, and that choice decides <em>which</em> pages Claude ever sees. If the right note is not in the results, Claude cannot use it — it never reached the prompt. So making Claude smarter about your brain is really about helping it <em>find</em> the right page, which is the embedding model's job.</p>
<p><code>nomic-embed-text</code> (768 dimensions) is a solid default. When you outgrow it, the usual step up is <code>bge-m3</code> (1024 dimensions): multilingual, and noticeably better at pulling the right page out of a large or messy brain. (<code>mxbai-embed-large</code> is a middle option; <code>bge-m3</code> is the one worth knowing.)</p>
<p>The catch: this is not a config toggle. The vector width — 768 for nomic — is baked into the database column at init time, so moving to a 1024-dimension model means wiping and re-embedding. Export first so nothing is lost:</p>
<pre><code class="language-bash">gbrain export --dir ~/brain-backup     # save your pages as markdown
ollama pull bge-m3
mv ~/.gbrain/brain.pglite ~/.gbrain/brain.pglite.bak
gbrain init --pglite --embedding-model ollama:bge-m3 --embedding-dimensions 1024
gbrain import ~/brain-backup           # re-embed every page with the new model
</code></pre>
<p>When is it worth it? When you have thousands of notes, or you write in more than one language, or search starts missing things you know are in there. Not on day one — on day one, <code>nomic-embed-text</code> plus the habit of capturing is the whole game.</p>
<p>(gbrain can also add a <em>reranker</em> — a second pass that re-orders the top hits for precision — but that is a later refinement. The embedding model and your notes are where the real gains live.)</p>
<h2>Teaching Claude to write good notes</h2>
<p>If notes are the biggest lever, the obvious next question is: will Claude write good ones on its own?</p>
<p>Partly. Left to the three-line brain-first protocol, it <em>will</em> save things — but the quality drifts. It might dump a whole conversation into one page, invent inconsistent slugs, or forget to link anything. Serviceable, not great. It does not magically write clean notes just because the tool is there.</p>
<p>Three things shape note quality — one you add, two gbrain already does.</p>
<p><strong>What you add: note conventions in</strong> <code>CLAUDE.md</code><strong>.</strong></p>
<p>The same file that tells Claude to search first can tell it <em>how</em> to write. Extend the protocol:</p>
<pre><code class="language-markdown">## Writing to the brain
When you save a page with put_page:
- One idea per page. Capture the specific decision or fact and the *why* — not a
  whole conversation.
- Namespace the slug by kind: people/&lt;name&gt;, companies/&lt;name&gt;, decisions/&lt;slug&gt;,
  notes/&lt;slug&gt;.
- Set an accurate type (note, person, company, decision) so it fits the schema.
- Link related pages with [[slug]] — a decision links to the people and projects it
  touches.
- Search first and update an existing page instead of creating a duplicate.
- Keep my exact wording for decisions and quotes; don't paraphrase away the specifics.
</code></pre>
<p>Now the agent files things the way you would, and the brain stays navigable instead of turning into a pile.</p>
<p><strong>What gbrain already does #1: it publishes its own filing rules.</strong></p>
<p>Because <code>mcp.publish_skills</code> is on, gbrain exposes <code>list_skills</code> / <code>get_skill</code> over MCP — the agent can ask the brain how <em>it</em> wants pages filed, and gbrain answers with its schema conventions. So you are mostly reinforcing habits the brain already advertises, not inventing them from scratch.</p>
<p><strong>What gbrain already does #2: it improves the notes over time, on its own.</strong></p>
<p>gbrain has an overnight maintenance pass — the "dream cycle" (<code>gbrain dream</code> once, or <code>gbrain autopilot --install</code> to run it continuously). It dedupes people pages, fixes broken citations, and wires up links you never made by hand. So even notes you wrote sloppily get tidied while you sleep. This one <em>does</em> use the chat model — so the dream cycle is exactly where a local qwen, or a bigger model, actually earns its keep (unlike live retrieval, which never touches it).</p>
<p>So the recipe for good notes: add a few conventions to <code>CLAUDE.md</code> for quality as they are written, lean on gbrain's published rules, and let the dream cycle polish the pile over time.</p>
<h2>The honest tradeoffs</h2>
<p>Real understanding includes the limits, so here they are.</p>
<p><strong>The simple path is not this one.</strong> The sanctioned, fewer-moving-parts setup is <em>local embeddings + cloud synthesis</em>: keep <code>nomic-embed-text</code> local and let a real Anthropic or OpenAI key write the answer. It is less setup and the prose is better. I chose full-local on purpose, and paid for it with a workaround and a smaller model.</p>
<p><strong>Local retrieval quality is lower.</strong> <code>nomic-embed-text</code> is good, but a hosted embedding model will find relevant notes more reliably on a large, messy brain. For a few thousand notes you will not notice. For a hundred thousand, you might.</p>
<p><strong>You are borrowing the OpenRouter namespace machine-wide.</strong> Putting <code>OPENROUTER_BASE_URL</code> and <code>OPENROUTER_API_KEY=ollama</code> in <code>~/.zshrc</code> sets them for <em>every</em> program in <em>every</em> shell. The day you install something that genuinely uses OpenRouter, it will quietly point at your Ollama with a dummy key and fail in a confusing way. If that day comes, move these exports out of <code>~/.zshrc</code> and <code>source env.sh</code> only when you use gbrain.</p>
<p><strong>Agentic features run hot.</strong> Pointing <code>models.default</code> at a local model means gbrain's agent loops (<code>gbrain agent run</code>, autopilot) run on qwen, which has no prompt caching — so long loops cost more time. <code>gbrain doctor</code> warns about exactly this and suggests keeping just that one tier on a cloud model. For plain <code>think</code> over your notes, it never comes up.</p>
<p>When should you <em>not</em> do this? If you have an API key you are comfortable using, and you value answer quality over privacy, go local-embeddings-plus-cloud-chat and skip the openrouter dance entirely. Full-local is for when "nothing leaves the machine" is the actual requirement, not a nice-to-have.</p>
<h2>The whole thing in three beats</h2>
<p>If you remember nothing else, remember the shape:</p>
<pre><code class="language-plaintext">Ingestion turns your notes into vectors, once, on your machine.
PGLite finds the right vectors when you ask, on your machine.
A local model reads them and writes the answer, on your machine.
</code></pre>
<p>One embedding model to remember. One chat model to explain. One file to hold it all. And a log full of <code>127.0.0.1</code> to prove it.</p>
<h2>Appendix: every command</h2>
<p>The whole surface of <code>gbrain 0.42.53.0</code>, grouped the way <code>gbrain --help</code> groups it. Prefix each with <code>gbrain</code>; run <code>gbrain &lt;command&gt; --help</code> for details. The ones you actually reach for day-to-day are marked ★.</p>
<p><strong>Setup &amp; health</strong></p>
<pre><code class="language-text">init [--pglite|--supabase|--url]      create a brain (PGLite = local, no server)
migrate --to &lt;supabase|pglite&gt;        move a brain between engines
upgrade                               self-update gbrain
check-update [--json]                 check for a new version
doctor [--json] [--fast]           ★  health check (embeddings, pgvector, skills…)
integrations [subcommand]             manage integration recipes (senses + reflexes)
</code></pre>
<p><strong>Get content in</strong></p>
<pre><code class="language-text">capture [content] [--file P] [--stdin]  ★ single entrypoint to add content (→ inbox/)
import &lt;dir&gt; [--no-embed]               ★ bulk-import a markdown directory
put &lt;slug&gt; [&lt; file.md]                    write/update one page
sync [--repo P] [--watch] [--install-cron] git repo → brain, incremental
embed [&lt;slug&gt;|--all|--stale]              (re)generate embeddings
</code></pre>
<p><strong>Read, search, ask</strong></p>
<pre><code class="language-text">search &lt;query&gt;                     ★  keyword search — ranked pages, no LLM
query &lt;question&gt; [--no-expand]     ★  hybrid search, meaning + keywords (alias: ask)
think &lt;question&gt;                   ★  synthesized, cited answer (uses the LLM)
get &lt;slug&gt;                            read one page
list [--type T] [--tag T] [-n N]      list pages
</code></pre>
<p><strong>Pages &amp; versions</strong></p>
<pre><code class="language-text">delete &lt;slug&gt;                         delete a page
history &lt;slug&gt;                        page version history
revert &lt;slug&gt; &lt;version-id&gt;            revert to a prior version
</code></pre>
<p><strong>The graph: links, tags, timeline</strong></p>
<pre><code class="language-text">link &lt;from&gt; &lt;to&gt; [--link-type T]      create a typed link (alias: link-add)
unlink &lt;from&gt; &lt;to&gt;                    remove a link (alias: link-rm)
link-sources                          list link provenances + edge counts
backlinks &lt;slug&gt;                      incoming links
graph &lt;slug&gt; [--depth N]              traverse the link graph
graph-query &lt;slug&gt; [--type T] [--direction in|out|both]  edge-filtered traversal
tags &lt;slug&gt; / tag &lt;slug&gt; &lt;t&gt; / untag &lt;slug&gt; &lt;t&gt;          list / add / remove tags
timeline [&lt;slug&gt;]                     view timeline
timeline-add &lt;slug&gt; &lt;date&gt; &lt;text&gt;     add a timeline entry
</code></pre>
<p><strong>Ideate (brainstorming over your brain)</strong></p>
<pre><code class="language-text">brainstorm &lt;question&gt; [--json]        bisociation idea generator (hybrid + far-set + judge)
lsd &lt;question&gt; [--json]               Lateral Synaptic Drift — far-from-obvious ideas
</code></pre>
<p><strong>Code indexing (for a synced codebase)</strong></p>
<pre><code class="language-text">code-def &lt;symbol&gt; [--lang l]          find a symbol's definition
code-refs &lt;symbol&gt; [--lang l]         find references to a symbol
code-callers &lt;symbol&gt;                 who calls this symbol
code-callees &lt;symbol&gt;                 what this symbol calls
query &lt;q&gt; --lang &lt;l&gt; | --symbol-kind &lt;k&gt;  filter hybrid search by language / symbol type
reconcile-links [--dry-run]           recompute doc↔impl edges
reindex-code [--source id] [--yes]    reindex code pages
sync --strategy code                  sync code files into the brain
</code></pre>
<p><strong>Multiple sources / repos</strong></p>
<pre><code class="language-text">sources list                          show registered sources
sources add &lt;id&gt; --path &lt;p&gt;           register a source
sources remove &lt;id&gt;                   remove a source + its pages
sync --all | --source &lt;id&gt;            sync all sources / one source
</code></pre>
<p><strong>Export &amp; files</strong></p>
<pre><code class="language-text">export [--dir ./out/]                 export the brain to markdown
files list [slug]                     list stored files
files upload &lt;file&gt; --page &lt;slug&gt;     attach a file to a page
files upload-raw &lt;file&gt; --page &lt;s&gt;    smart upload (size routing + redirect)
files signed-url &lt;path&gt;               1-hour signed URL
files sync &lt;dir&gt; / files verify       bulk upload / verify uploads
</code></pre>
<p><strong>Maintenance &amp; tools</strong></p>
<pre><code class="language-text">extract &lt;links|timeline|all&gt;          extract links/timeline (idempotent)
lint &lt;dir|file&gt; [--fix]               catch LLM artifacts, bad frontmatter, placeholder dates
orphans [--json] [--count]            pages with no inbound links
check-backlinks &lt;check|fix&gt; [dir]     find/fix missing backlinks
salience [--days N] [--kind P]        pages ranked by emotional + activity salience
anomalies [--since D] [--sigma N]     cohort-based statistical anomalies
transcripts recent [--days N]         recent raw local transcripts
dream [--dry-run] [--json]            run the overnight maintenance cycle once
publish &lt;page.md&gt; [--password]        shareable HTML (strips private data, optional AES-256)
report --type &lt;name&gt; --content ...    save a timestamped report to the brain
check-resolvable [--json] [--fix]     validate the skill tree (reachability/MECE/DRY)
</code></pre>
<p><strong>Background jobs (Minions — Postgres/Supabase only)</strong></p>
<pre><code class="language-text">jobs submit &lt;name&gt; [--params JSON]    submit a background job [--follow]
jobs list | get &lt;id&gt; | cancel &lt;id&gt; | retry &lt;id&gt;   manage jobs
jobs prune [--older-than 30d] | stats | work      clean / dashboard / worker daemon
</code></pre>
<p><strong>Serve &amp; connect an agent</strong></p>
<pre><code class="language-text">serve                              ★  MCP server over stdio (what `claude mcp add` runs)
serve --http [--port N]               HTTP MCP server with OAuth 2.1
connect &lt;mcp-url&gt; --token &lt;t&gt; [--install]  wire this machine to a remote gbrain
watch [--json]                        pipe conversation turns in, stream brain pages out
call &lt;tool&gt; '&lt;json&gt;'                  raw tool invocation
--tools-json                          tool discovery (JSON)
</code></pre>
<p><strong>Admin</strong></p>
<pre><code class="language-text">stats                                 brain statistics
health                                brain health dashboard
features [--json] [--auto-fix]        scan usage, recommend unused features
autopilot [--repo] [--interval N]     self-maintaining brain daemon
config [show|get|set] &lt;key&gt; [val]  ★  brain config (e.g. config set chat_model …)
storage status [--json]               storage tier status and health
version                               version info
</code></pre>
<p><code>think</code> is the odd one out: it is not printed in <code>gbrain --help</code>'s summary, but it is real and it is the command that writes a synthesized, cited answer. <code>search</code>, <code>query</code>, and <code>ask</code> return ranked pages; <code>think</code> writes the prose.</p>
]]></content:encoded></item><item><title><![CDATA[Ever wonder how real-time collaborative editing actually works?]]></title><description><![CDATA[One walkthrough of the whole real-time layer of a collaborative document editor — the merge, the transport, the editor, and the persistence — built by hand on Y.js to actually understand it. Code refe]]></description><link>https://featuringcode.com/ever-wonder-how-real-time-collaborative-editing-actually-works</link><guid isPermaLink="true">https://featuringcode.com/ever-wonder-how-real-time-collaborative-editing-actually-works</guid><category><![CDATA[YJS]]></category><category><![CDATA[realtime]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Mon, 29 Jun 2026 17:58:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/995f809f-38b4-4438-af83-201433c2e75b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>One walkthrough of the whole real-time layer of a collaborative document editor — the merge, the transport, the editor, and the persistence — built by hand on <a href="https://yjs.dev/">Y.js</a> to actually understand it. Code references are <a href="https://github.com/mmswi/miniSocialApp/tree/feature/editor">real</a>; every worked CRDT example below was run through Yjs before it was written down.</p>
</blockquote>
<p>Two people open the same document.</p>
<p>They both start typing into the same paragraph, at the same instant.</p>
<p>Neither has seen the other's keystrokes yet.</p>
<p>And a second later, both screens show the exact same text — with nobody's edit lost, and no central server deciding who won.</p>
<p>That last clause is the hard part.</p>
<p>No referee.</p>
<p>The usual way this gets explained is a pile of vocabulary:</p>
<p>CRDT. Operational transform. State vector. Tombstone. WebSocket upgrade. Snapshot. Compaction.</p>
<p>Which is not an explanation. It is a list of things you are now supposed to understand.</p>
<p>I built the whole real-time layer of a collaborative review tool by hand — on Yjs for the merge itself, but the transport, the reconnect, and the persistence all hand-rolled — specifically so I could explain every piece instead of importing a black box. So here is the box, opened.</p>
<p>One example runs through the entire post. Mara and Theo, editing a document called the <em>Q3 strategy memo</em>. Mara is at the top fixing a heading. Theo is three paragraphs down rewriting a sentence. Keep them in mind; every part below is really about them.</p>
<p>The shape of the whole thing, before we start:</p>
<pre><code class="language-plaintext">keystroke → a CRDT update → over a WebSocket → to a relay → to everyone else → and onto disk
</code></pre>
<p>We will go in dependency order: first how edits <em>merge</em>, then how they <em>travel</em>, then how the <em>editor</em> hooks in, then how they are <em>saved</em>.</p>
<hr />
<h2>Part 1 — The merge: how two keystrokes don't clobber</h2>
<p>Start with the naive model, because it is the one everyone reaches for, and watch it break.</p>
<p>Model the document as a string. An edit names a position:</p>
<pre><code class="language-plaintext">"insert 'X' at position 5"
"delete position 3"
</code></pre>
<p>Now run Mara and Theo concurrently. The document is <code>base</code>. Neither has seen the other:</p>
<pre><code class="language-plaintext">Mara:  insert 'X' at position 0
Theo:  delete position 2          (he means the 's')
</code></pre>
<p>Apply Mara's, then Theo's:</p>
<pre><code class="language-plaintext">"base"  --insert 'X' at 0--&gt; "Xbase"
"Xbase" --delete position 2--&gt; "Xbse" ← deleted 'a', not 's'
</code></pre>
<p>Theo meant to delete the <code>s</code> — index 2 in <code>base</code>. But Mara's insert shifted every position right by one, so in <code>Xbase</code> index 2 is now <code>a</code>. His delete hit the wrong character.</p>
<p>A position is not stable. It means one thing before a concurrent edit and another thing after.</p>
<p>Operational Transformation (OT) patches this by <em>transforming</em> one operation against the other — "Theo's index 2 has to become 3, because Mara inserted to its left." It works, and it is notoriously hard to get right; every pair of operation types needs its own transform.</p>
<p>A CRDT — Conflict-free Replicated Data Type — takes the other road. It builds positions that never shift.</p>
<h3>Give every character a permanent name</h3>
<p>Stop saying "position 5." Say "the character <em>between this one and that one</em>." If characters have permanent names, "between X and Y" means the same thing forever.</p>
<p>Three pieces make that work. This is <strong>YATA</strong> (Yet Another Transformation Approach), the algorithm inside Yjs:</p>
<p><strong>1. Every character is an <em>item</em> with a permanent id</strong> — a pair <code>(client, clock)</code>. The <code>client</code> is unique per editor (Mara is 1, Theo is 2). The <code>clock</code> ticks up as that client creates items. Mara's first character is <code>(1,0)</code>, her next <code>(1,1)</code>. The id never changes.</p>
<p><strong>2. The document is a doubly-linked list of items, not a string.</strong> To read the text you <em>walk</em> the list and concatenate. No character stores a position number — a character's position is simply where it falls as you walk the list.</p>
<pre><code class="language-plaintext">(start) ⇄ [ H (1,0) ] ⇄ [ i (1,1) ] ⇄ (end)
          each item = { id, origin, content, deleted }
</code></pre>
<p><strong>3. Each item remembers its <em>origin</em></strong> — the id of the item to its left at the moment it was inserted. That is its anchor: "I went in right after <code>(1,0)</code>." Because ids never change, that anchor is permanent.</p>
<p>Inserting is now just: make an item, point its origin at the left neighbor's id, splice it in. No offsets exist, so nothing can shift out from under anyone.</p>
<h3>The hard case, and the tiebreak that saves it</h3>
<p>Empty document. Mara and Theo both type at the very start, same instant:</p>
<pre><code class="language-plaintext">Mara inserts 'A'  →  (1,0), origin = (start)
Theo inserts 'B'  →  (2,0), origin = (start)
</code></pre>
<p>Both items have the <em>same origin</em>. Both want to be first. Every replica has to pick an order — and it must be the <strong>same</strong> order everywhere, or Mara sees <code>AB</code>, Theo sees <code>BA</code>, and they have diverged for good.</p>
<p>YATA's rule: items sharing an origin are ordered by <strong>client id</strong> — the same comparison on every machine. Lower id to the left. (The full rule has a few more cases, for inserts whose origins interleave, but they all resolve the same way: a fixed comparison on immutable ids, so every replica agrees.)</p>
<p>So <code>(1,0) 'A'</code> lands left of <code>(2,0) 'B'</code>, on every replica, because the client ids ride inside the items themselves. I ran exactly this through Yjs:</p>
<pre><code class="language-plaintext">apply Mara's update then Theo's  →  "AB"
apply Theo's update then Mara's  →  "AB"     (same answer, either order)
</code></pre>
<p>Both edits survived. The tiebreak only chose their <em>order</em>, and chose it identically for everyone. That is the whole trick: <strong>a conflict becomes a deterministic ordering, never a winner and a loser.</strong></p>
<p>If the two inserts had <em>different</em> origins, there is nothing to resolve. Theo inserts <code>X</code> before <code>b</code> (origin = the start), Mara inserts <code>Y</code> after <code>e</code> (origin = <code>e</code>), and the merge is just <code>"XbaseY"</code> (verified) — both survive, because they were never competing for the same anchor.</p>
<p>And notice what is <em>not</em> happening: Theo has the higher client id, yet his <code>X</code> still lands on the left. No client-id comparison runs here at all — the order falls straight out of the origins. The tiebreak from the last section only fires when two items fight over the <em>same</em> origin. This is Mara's heading and Theo's sentence: different places, no conflict.</p>
<h3>Deletes leave a tombstone</h3>
<p>One subtlety keeps it all consistent. If you truly spliced a deleted character out of the list, any item anchored to it would lose its origin. So you never remove — you mark the item <strong>deleted</strong>, a <strong>tombstone</strong>, and skip it when rendering.</p>
<p>Document is <code>XY</code>. Mara deletes <code>X</code>; Theo, concurrently, inserts <code>Z</code> right after <code>X</code>:</p>
<pre><code class="language-plaintext">[ X (deleted) ] ⇄ [ Z ] ⇄ [ Y ]
   skip            keep    keep
render → "ZY"     (verified)
</code></pre>
<p><code>X</code> is invisible but still present as an anchor, so <code>Z</code> (origin <code>X</code>) lands exactly where <code>X</code> was. The tombstone keeps the map intact.</p>
<h3>Why it always converges</h3>
<p>Three facts together are the entire guarantee:</p>
<ul>
<li><p>ids are immutable,</p>
</li>
<li><p>origins point at ids, so they are stable,</p>
</li>
<li><p>the tiebreak is a fixed function of immutable data (client ids).</p>
</li>
</ul>
<p>So applying the same updates in <em>any</em> order builds the same list, which renders the same text. "Yjs updates are commutative" is not magic — it is <em>every decision is a fixed function of data every replica already has</em>.</p>
<p>That commutativity is the property everything else in this post leans on.</p>
<hr />
<h2>Part 2 — Getting every keystroke to everyone (the sync layer)</h2>
<p>Yjs gives us mergeable updates. It does not move them between people. That is the part I hand-built.</p>
<p>When Mara types, Yjs emits a small binary <strong>update</strong> — the diff of that one change, encoding <em>where</em> it belongs logically (an item with an origin), not "characters 40–58." Our job is only to get every update to every participant, exactly enough times.</p>
<p>The flow of one keystroke, watching where each step runs:</p>
<pre><code class="language-plaintext">Mara's browser (client)
↓  Yjs emits an update
send it over the WebSocket            (client → server)
↓
server applies it to the room's Y.Doc  (server, in memory)
↓
├─► append the update to Postgres       (durable immediately — Part 4)
└─► broadcast to every OTHER client      (server → Theo, NOT back to Mara)
↓
Theo's browser applies it, and sees Mara's edit
</code></pre>
<p>The server is <strong>a relay with a memory</strong>. It is not a participant and has no opinion about the document. It holds the current <code>Y.Doc</code> for one document in RAM so it can answer "what's the state?", it forwards updates between the people connected, and it writes every update down. One document's relay is a <strong>room</strong>, keyed by document id — everyone editing that document shares one room, one in-memory <code>Y.Doc</code>, one set of connections.</p>
<h3>How a newcomer catches up</h3>
<p>When Theo opens the memo, his browser's <code>Y.Doc</code> is empty and the server's is full — and Theo might <em>also</em> have offline edits the server has never seen. So catch-up has to go both ways.</p>
<p>This is what <strong>state vectors</strong> are for. A state vector is a compact summary of "how much of each author's changes I already have" — not the content, just the watermarks: a map of <code>client → the highest clock I've seen from them</code>. (Now you can see why Part 1's ids matter — the ids <em>are</em> the bookmarks.)</p>
<p>The exchange is two steps, each direction:</p>
<pre><code class="language-plaintext">Theo connects
↓
server → Theo:  SyncStep1 = "here is my state vector"
↓
Theo → server:  SyncStep2 = "based on yours, here's everything you're missing from me"
              + SyncStep1 = "and here is MY state vector"
↓
server → Theo:  SyncStep2 = "based on yours, here's everything you're missing from me"
</code></pre>
<p>After that both sides have everything, and they stay in sync by streaming updates as they happen. In code it is almost anticlimactic, because Yjs's <code>y-protocols</code> does the vector math — the server just sends the opening message the instant a connection joins:</p>
<pre><code class="language-typescript">// src/sync/doc-room.ts — addConnection
conn.send(encodeSyncStep1(doc))   // "here is my state vector; tell me what you lack"
</code></pre>
<p>The beautiful part: <strong>reconnect is not a special case.</strong> A returning client is just a newcomer whose <code>Y.Doc</code> happens to be half-full. SyncStep1/Step2 figures out the difference and replays exactly the missing updates. There is no separate "reconnect" code path — reconnect <em>is</em> the handshake.</p>
<h3>The bug every hand-built relay has: the echo</h3>
<p>Here is the trap. The server receives Mara's update and broadcasts it to everyone in the room. If "everyone" includes Mara, she receives her own edit back, applies it, re-emits it, sends it again... an echo, then a loop.</p>
<p>The fix is the <strong>self-echo guard</strong>: broadcast to everyone <em>except the connection the update came from</em>. The mechanism is Yjs's transaction <em>origin</em> — when the server applies Mara's update it tags the transaction with Mara's connection as the origin, and the room's single update handler fans out to everyone but that origin:</p>
<pre><code class="language-typescript">// src/sync/doc-room.ts
doc.on('update', (update, origin) =&gt; {
  broadcast(encodeUpdate(update), senderOf(origin))   // senderOf(origin) is skipped
  void appendUpdate(documentId, update)               // and persisted
})
</code></pre>
<p>There is a second, quieter guard Yjs gives you for free, and it matters most on reconnect. When Theo comes back and his SyncStep2 replays updates the server <em>already has</em>, applying them changes nothing — and <strong>Yjs only fires the</strong> <code>update</code> <strong>event for changes that actually mutate the doc.</strong> So a redundant replay triggers neither a re-broadcast nor a duplicate row in the log. Convergence stays correct and persistence stays clean with zero lines of dedup code.</p>
<h3>Why it is all bytes (lib0, and why we encode and decode)</h3>
<p>How does an update actually get <em>onto</em> the socket? Three words in the code look like noise — <a href="https://www.npmjs.com/package/lib0"><code>lib0</code></a>, <code>encoder</code>, <code>varUint</code> — and they are exactly the answer. Start with the data.</p>
<p>Mara types one character. Yjs hands us an update. It is not text. It is a <code>Uint8Array</code> — a handful of raw bytes:</p>
<pre><code class="language-plaintext">[ 0x01 0x01 0x84 … ]   // a Yjs update; the exact bytes are Yjs's business, not ours
</code></pre>
<p>And the same socket also has to carry <strong>presence</strong> — who is in the room, where each cursor is. Two kinds of traffic, one socket.</p>
<p><strong>The bad version is JSON.</strong> The reflex is <code>ws.send(JSON.stringify({ type: 'sync', update }))</code>. It breaks on the first line: the update is binary, and JSON cannot hold raw bytes. You would have to convert it — base64 (bigger, and still opaque text you decode again), or a JSON array of numbers like <code>[1, 1, 132, 47, 120, 1]</code>. A 6-byte update just became ~50 bytes of text, on every keystroke, and the receiver has to parse it back into bytes before Yjs can even look at it. You pay to turn bytes into text, send more of it, then turn it back into bytes. For nothing. So we stay in bytes the whole way.</p>
<p><code>lib0</code> <strong>is the toolkit Yjs is built on.</strong> The binary primitives live there — a growable byte buffer, variable-length integers, the matching read/write pair. Yjs uses them internally, and so does <code>y-protocols</code>, the helper that gives us <code>writeSyncStep1</code>, <code>writeUpdate</code>, and <code>readSyncMessage</code>. That last fact is the whole reason we use lib0 and nothing else: the update we are wrapping was produced by lib0, and the helpers that read it expect a lib0 buffer. Pick any other serializer and you cannot compose with the very functions doing the sync.</p>
<p><strong>An encoder is a buffer you append into.</strong> You write typed values in order, then ask for the finished bytes; a decoder is the exact mirror, reading them back <em>in the same order they were written</em>:</p>
<pre><code class="language-plaintext">createEncoder()              start an empty buffer
writeVarUint(enc, n)         append a small number
writeVarUint8Array(enc, b)   append a chunk of bytes, length-prefixed
toUint8Array(enc)            the finished buffer to send
</code></pre>
<p>That ordering <em>is</em> the protocol: write tag, then payload; read tag, then payload. Read them out of order and you get garbage, with no error to tell you. A <code>varUint</code> (variable-length unsigned integer) costs one byte for a small number — our channel tag is <code>0</code> or <code>1</code>, where a fixed 32-bit int would spend four to say "0." It is the same encoding Yjs uses internally, so it is already the dialect the buffer speaks.</p>
<p>The framing, end to end:</p>
<pre><code class="language-typescript">// src/sync/sync-protocol.ts
export const encodeUpdate = (update: Uint8Array): Uint8Array =&gt; {
  const encoder = encoding.createEncoder()
  encoding.writeVarUint(encoder, SYNC_MESSAGE.sync)   // 1 byte: "channel 0 — a document update"
  writeUpdate(encoder, update)                         // append the payload, length-prefixed
  return encoding.toUint8Array(encoder)
}
</code></pre>
<pre><code class="language-plaintext">Mara's update:   « a few opaque bytes from Yjs »
↓  writeVarUint(sync)   prepends the tag byte 0x00
↓  writeUpdate          appends the payload, length-prefixed
framed message:  [ 0x00 | «length» | « the update bytes » ]
</code></pre>
<p>The other end reads the tag first, and the tag decides who handles the rest:</p>
<pre><code class="language-plaintext">tag 0  →  sync       the document CRDT (the handshake + ongoing updates)
tag 1  →  awareness  ephemeral presence (who's here, cursor positions)
</code></pre>
<p>That one byte is how a single socket multiplexes both channels — the cheapest multiplexer there is. A wrong byte is a silent disaster (presence bytes parsed as a document update), so the tag values live in exactly one place both ends import, never as bare <code>0</code>/<code>1</code>:</p>
<pre><code class="language-typescript">// src/sync/sync-protocol.ts
export const SYNC_MESSAGE = { sync: 0, awareness: 1 } as const
</code></pre>
<p>The price of going binary is debuggability — you cannot eyeball a byte buffer in the network tab. We pay it because the payload is <em>already</em> binary and we must interoperate with y-protocols; JSON would only add cost and a translation layer.</p>
<h3>The door: authorize before the socket opens</h3>
<p>A WebSocket starts life as a normal HTTP request asking to "upgrade" to a socket. That request carries cookies — so authorization happens <em>there</em>, before any socket exists:</p>
<pre><code class="language-typescript">// src/sync/ws-routes.ts — preValidation, before the upgrade completes
const active = rawToken === undefined ? null : await getSessionUser(rawToken)
if (active === null) return reply.code(401).send({ error: 'not_authenticated' })
const document = await getDocumentForOwner({ documentId, ownerId: active.userId })
if (document === null) return reply.code(404).send({ error: 'document_not_found' })
</code></pre>
<p>An unauthorized client never gets a live connection — just a plain HTTP error on the upgrade. And it is the <strong>same</strong> owner check the REST routes use, so "can read this over REST" and "can join its live editing room" can never drift apart.</p>
<p>That <code>404</code>, not <code>403</code>, is deliberate. A <code>403</code> ("forbidden") would confirm the document exists — a stranger now knows there is a real document at that id, they just can't have it. A <code>404</code> says nothing: yours or imaginary, you get the same answer. The endpoint is not an existence oracle. (For now the authorization rule is the simplest possible one — you can see a document only if you own it. Teams and roles come later.)</p>
<h3>What is honest about this, and what is deferred</h3>
<p><strong>It is single-instance.</strong> One server process holds each room. Run two processes behind a load balancer and Mara could land on process A, Theo on process B, and B's room never hears A's updates. The fix is cross-instance fan-out: publish each update to a Redis channel and have every process relay what it hears, ignoring its own echoes by an instance id. It slots into the exact same <code>doc.on('update')</code> handler — which is why the persistence and broadcast already live there.</p>
<p><strong>The durable write is fire-and-forget.</strong> The update is broadcast and <code>appendUpdate</code> is <em>issued</em>, but not awaited before the client moves on. The data-loss window is tiny, not zero — and, as Part 4 shows, it is exactly this fire-and-forget concurrency that makes compaction's bookkeeping subtle. (One operational gotcha worth recording: on Bun, <code>app.close()</code> waits forever on an open WebSocket, so graceful shutdown has to close the sockets first.)</p>
<hr />
<h2>Part 3 — From keystroke to pixels (the browser)</h2>
<p>That covered the server. The browser is the other end — what happens when Mara presses a key, and how three pieces hand work to each other. Keep them separate; it is easy to blur them:</p>
<pre><code class="language-plaintext">TipTap        the editor UI — toolbar behavior, what a heading is, where the cursor blinks
Yjs           the CRDT — the mergeable document state, emits an "update" on every change
our provider  the network — carries Yjs updates over the WebSocket to the server and back
</code></pre>
<p>TipTap does not know about the network. Yjs does not know about the network. <strong>The provider is the only piece that touches the socket.</strong> That separation is the whole design: swap the provider and nothing else changes.</p>
<p>The glue between TipTap and Yjs is one TipTap extension, <code>Collaboration</code>. It replaces TipTap's normal "store the document in the editor" with "store the document in this <code>Y.Doc</code>." After that, every edit Mara makes is a Yjs update, automatically:</p>
<pre><code class="language-typescript">// web/src/editor/CollaborativeEditor.tsx
extensions: [
  ...documentExtensions,
  Collaboration.configure({ document: doc }),                        // editor edits → Y.Doc updates
  CollaborationCaret.configure({ provider, user: { name, color } }), // others' cursors from awareness
]
</code></pre>
<p>The flow of one keystroke, in the browser:</p>
<pre><code class="language-plaintext">Mara types a character (TipTap)
↓
Collaboration writes it into the Y.Doc
↓
the Y.Doc emits an "update" event
↓
our provider's doc.on('update') fires → encode as a 'sync' message, ws.send → (server)
... meanwhile, a message arrives from the server ...
ws.onmessage → readSyncMessage applies it to the Y.Doc (origin = remote)
↓
Collaboration sees the Y.Doc change and re-renders the editor
↓
Theo's edit appears in Mara's editor
</code></pre>
<p>Notice Mara never re-sends what she just received. The provider applies remote updates with a sentinel origin and skips sending anything tagged with it — the <strong>same self-echo guard as the server</strong>, on the client side:</p>
<pre><code class="language-typescript">// web/src/editor/sync-provider.ts
const remoteOrigin = { remote: true }
const onDocUpdate = (update, origin) =&gt; {
  if (origin === remoteOrigin) return   // came FROM the server — don't bounce it back
  send(encodeUpdate(update))            // a genuine local edit — send it up
}
</code></pre>
<h3>Why the schema has to be shared</h3>
<p>Here is the fact that makes this section click, because the name "extensions" hides it.</p>
<p>A <strong>schema</strong> is the grammar of a document: which node types may exist (<code>paragraph</code>, <code>heading</code>, <code>bulletList</code>, <code>codeBlock</code>…), which marks (<code>bold</code>, <code>italic</code>…), and how they nest. A document containing a node the schema doesn't define is invalid — there is nowhere to put it.</p>
<p>In raw ProseMirror you write that grammar by hand. In TipTap you do not: <strong>TipTap derives the schema from your list of extensions.</strong> The array <em>is</em> the schema:</p>
<pre><code class="language-typescript">// web/src/editor/document-extensions.ts
export const documentExtensions = [StarterKit.configure({ undoRedo: false })]
</code></pre>
<p>Each extension contributes a piece of the grammar — <code>Heading</code> declares the <code>heading</code> node, <code>Bold</code> declares the <code>bold</code> mark; <code>StarterKit</code> bundles ~15 of them. TipTap runs <code>getSchema(documentExtensions)</code> and assembles one concrete schema. Same list in, byte-identical schema out. (<code>undoRedo: false</code> is the exception that proves the rule — undo/redo is a <em>behavior</em> plugin, adding no node or mark type, so it doesn't touch the schema. We turn it off because Yjs owns undo for a shared document.)</p>
<p>Now the trap. A Yjs update names node types <strong>by their schema name</strong>:</p>
<pre><code class="language-plaintext">update says:  insert a node of type "heading", level 2, containing the text "Budget"
</code></pre>
<p>To apply that, the receiving client hands it to the ProseMirror↔Yjs binding, which looks up <code>"heading"</code> in <strong>its own</strong> schema to rebuild the node. So picture two clients with different extension lists:</p>
<pre><code class="language-plaintext">Client A's extensions include Heading  →  A's schema knows "heading"
Client B's extensions do not            →  B's schema has no "heading"
</code></pre>
<p>Mara inserts a heading on A. The update reaches B. The binding on B tries to build a <code>"heading"</code> node, finds nothing in its schema, and cannot map it — it throws or drops the content. <strong>Desync.</strong> The two ends never disagreed about the <em>text</em>; they disagreed about which node types <em>exist</em>.</p>
<p>So the extension list lives in <strong>one shared module</strong> with no React and no networking — the single source of truth for "what a document may contain." Every browser editor imports it (so all clients decode each other's updates identically), and a server-side import pipeline imports it too (it builds documents without a browser, and must seed them with the exact same schema, or the first client to open one receives nodes its schema can't read). It must be <em>shared</em>, never <em>copied</em> — the day someone adds an extension to one copy and not the other, documents silently break.</p>
<h3>Reconnect: the provider survives a bad network</h3>
<p>Mara's wifi drops. The socket closes. What should happen? Nothing visible — she keeps typing.</p>
<p>That works because of the layering. Her edits still flow into the <code>Y.Doc</code> — Yjs doesn't care the socket is gone — and queue up as document state. Meanwhile the provider notices the close and reconnects with exponential backoff plus jitter:</p>
<pre><code class="language-typescript">// web/src/editor/sync-provider.ts
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** (reconnectAttempts - 1))  // 1s, 2s, 4s … cap 15s
const delay = backoff + backoff * 0.3 * Math.random()                          // jitter: no thundering herd
</code></pre>
<p>When the socket reopens, the provider sends SyncStep1 again, and the handshake replays exactly what each side missed. Mara's offline edits go up; everything that happened while she was gone comes down. The "reconnect" path is just the "connect" path — the CRDT handshake <em>is</em> the catch-up. The jitter matters at scale: if a server restarts and 200 clients all reconnect on the same 1-second timer, they hammer it in lockstep; a random 0–30% spread turns a spike into a smear. (True half-open detection — a socket dead but never firing <code>close</code> — needs an application heartbeat, still on the list; today we reconnect on the <code>close</code> event, which covers the common cases.)</p>
<h3>The React lifecycle gotcha</h3>
<p>There is one non-obvious bug this code is shaped to avoid, and the fix only makes sense once you've seen the broken version.</p>
<p>The instinct is <code>useMemo</code>. The <code>Y.Doc</code> and provider are expensive and you want them built <em>once per document</em>, not rebuilt on every render — that is exactly what <code>useMemo</code> is for. You memoize them, and tear them down in an effect cleanup:</p>
<pre><code class="language-typescript">// THE BROKEN VERSION — do not ship this
const doc = useMemo(() =&gt; new Y.Doc(), [documentId])
const provider = useMemo(
  () =&gt; createSyncProvider({ documentId, doc, onStatusChange: setStatus }),
  [documentId, doc],
)

useEffect(() =&gt; {
  // ◄── THIS returned function is the cleanup. It tears down BOTH memoized resources:
  return () =&gt; {
    provider.destroy()   // closes the WebSocket and stops syncing  → dead socket
    doc.destroy()        // tears down the CRDT state TipTap is bound to → dead document
  }
}, [provider, doc])
</code></pre>
<p>Both lines matter equally — neither resource is rebuilt after the cycle below, so the editor ends up bound to a dead socket <em>and</em> a dead <code>Y.Doc</code>. (If anything <code>doc.destroy()</code> is the worse one: a dead socket only stops network sync, but a destroyed <code>Y.Doc</code> is the editor's whole data model, gone.)</p>
<p>It reads correctly. It even works in production. It is broken in development, and the cause is <strong>React StrictMode</strong>.</p>
<p>In dev, StrictMode deliberately mounts every component, immediately unmounts it, then mounts it again — once — to surface effects that don't survive a remount. The subtlety is <em>what</em> it does across that cycle: it re-runs your <strong>effects</strong> (cleanup, then setup again), but it does <strong>not</strong> re-run the component's <code>useMemo</code> factories. There is no fresh render in between, and the deps (<code>documentId</code>) haven't changed — so the memo keeps handing back the instance it already built.</p>
<p>Trace it:</p>
<pre><code class="language-plaintext">1. Mount
   useMemo builds doc D1 + provider P1        (P1 opens the WebSocket)
   effect setup runs                           (no-op — the effect is only a cleanup)
2. Simulated unmount
   effect CLEANUP runs → provider.destroy() AND doc.destroy()    ◄── both torn down here
                                              (P1's socket closed, D1's CRDT state gone)
3. Simulated remount
   NO re-render, deps unchanged →
     useMemo returns the SAME D1, P1           (the factory does NOT run again)
   effect setup runs again                      (still a no-op)
4. The component now holds D1 and P1 — both destroyed in step 2, neither rebuilt.
   Dead socket AND dead document. The editor renders against a corpse.
</code></pre>
<p>The bug is a <strong>split between creation and destruction</strong>. Creation ran <em>once</em>, in the memo, and survives the remount. Destruction runs on <em>every</em> unmount, in the effect cleanup. One StrictMode cycle gives you one destroy and zero rebuilds — and they are now out of sync.</p>
<p>The fix is to put creation and destruction in the <strong>same place</strong> — the effect — so every teardown is paired with a fresh build:</p>
<pre><code class="language-typescript">// web/src/pages/DocumentEditorPage.tsx — the real version
useEffect(() =&gt; {
  const doc = new Y.Doc()
  const provider = createSyncProvider({ documentId, doc, onStatusChange: setStatus })
  setSession({ doc, provider })            // hand the fresh pair to render
  return () =&gt; {
    provider.destroy()
    doc.destroy()
    setSession(null)
  }
}, [documentId])
</code></pre>
<p>Now the StrictMode remount re-runs the effect setup, which <strong>builds a fresh D2 + P2</strong> — so every destroy is matched by a build, and the editor renders against a live pair. In production, where the double-mount doesn't happen, the effect just runs once. (Because the pair is built <em>inside</em> the effect, after the first render, the first render has no provider yet — which is why it lives in <code>session</code> state with a <code>Loading editor…</code> fallback.)</p>
<hr />
<h2>Part 4 — Never losing a word (durable state + compaction)</h2>
<p>The document's live state is a CRDT in memory. Memory is gone the instant the process restarts. So how do we save it without saving too much, or losing the last few seconds of typing?</p>
<h3>Where the content actually lives</h3>
<p>Here is the surprising part. The <code>documents</code> row does <strong>not</strong> hold the text of the memo:</p>
<pre><code class="language-typescript">// src/db/schema.ts
export const documents = pgTable('documents', {
  id: uuid('id').defaultRandom().primaryKey(),
  ownerId: uuid('owner_id').notNull().references(() =&gt; users.id, { onDelete: 'cascade' }),
  title: text('title').notNull().default('Untitled document'),
  snapshot: bytea('snapshot'),            // &lt;- the content, but NULL right now
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
</code></pre>
<p>When Mara creates the memo, <code>snapshot</code> is <code>NULL</code>. The document is real — it has an id, a title, an owner — it just has no content yet. The content is the CRDT, and it is not written here the way you write a string to a column; it gets <em>seeded</em> the first time someone opens the memo and types. So the interesting design is not the columns. It is <em>how the content gets saved</em> once it exists.</p>
<h3>The naive save loses work</h3>
<p>The obvious way to persist an editor: every time the document changes, write the whole thing back.</p>
<pre><code class="language-plaintext">Mara types a word  →  serialize the entire document  →  UPDATE documents SET snapshot = &lt;whole doc&gt;
</code></pre>
<p>Bad two ways. It rewrites the <em>entire</em> document on every keystroke — a 40-page memo, re-serialized because she fixed a typo. So people debounce it: "only save every 2 seconds." Which trades one problem for a worse one. Picture the process dying at second 1.9 — a redeploy, a crash, a reboot. The last ~2 seconds of everyone's typing are gone, because they only ever lived in memory, waiting for a timer that never fired. For a tool whose whole point is collaborative editing, silently dropping the last edit is the cardinal sin.</p>
<h3>Two tiers: append now, fold later</h3>
<p>A CRDT does not force you to choose between "save everything" and "save rarely." Every change produces a small binary <strong>update</strong> — the diff of just that change. So we keep two tiers.</p>
<p><strong>Tier one — the append-only log.</strong> The instant the sync server receives an update, it appends it as a row. Durable <em>now</em>, no timer:</p>
<pre><code class="language-typescript">// src/db/schema.ts
export const documentUpdates = pgTable('document_updates', {
  seq: bigserial('seq', { mode: 'number' }).primaryKey(),
  documentId: uuid('document_id').notNull().references(() =&gt; documents.id, { onDelete: 'cascade' }),
  update: bytea('update').notNull(),     // one Yjs update — the diff of a single change
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
</code></pre>
<p><strong>Tier two — the snapshot.</strong> A background worker periodically <strong>folds</strong> the log into one compacted blob. To <em>fold</em> is to take the snapshot plus the loose update rows, replay them into one <code>Y.Doc</code>, re-encode that single state, write it back as the new snapshot, and delete the rows it folded — many small diffs collapse into one. Loading a document back is the same idea in reverse:</p>
<pre><code class="language-plaintext">snapshot  +  replay every update row still in the log  =  the current document
</code></pre>
<p>The append is what kills the data-loss window: the change is on disk before the editor repaints. The snapshot is just an optimization on top, so the log never has to be replayed from the beginning of time.</p>
<h3>The log only grows</h3>
<p>Why bother folding at all? Because the append log has a second cost. Each keystroke is a row, and the log only grows:</p>
<pre><code class="language-plaintext">day 1:   snapshot = ∅   +   replay 200 rows      → fast
day 7:   snapshot = ∅   +   replay 50,000 rows   → slow, and getting slower
</code></pre>
<p>The append bought durability and charges for it in load time. Nothing trims the log, so the bill grows forever. <strong>Compaction</strong> is the housekeeping that pays it: every so often, fold the pile of little updates into one snapshot blob and delete the rows it merged. After a fold, loading reads one blob and replays nothing.</p>
<p>It helps to say what compaction is <strong>not</strong>. It is not required for correctness — the append already made every edit durable; if the worker never ran, nothing would be lost, loads would just keep getting slower. It is not on the editing path — Mara never waits for it; it runs in a separate worker process. And it is not a debounce — the durable write already happened; compaction only <em>consolidates</em> what is already on disk.</p>
<h3>The fold, in code</h3>
<pre><code class="language-typescript">// src/sync/compaction.ts — compactDocument, all inside one transaction
const pending = await tx
  .select({ seq: documentUpdatesTable.seq, update: documentUpdatesTable.update })
  .from(documentUpdatesTable)
  .where(eq(documentUpdatesTable.documentId, documentId))
  .orderBy(asc(documentUpdatesTable.seq))

const doc = new Y.Doc()
if (meta.snapshot !== null) Y.applyUpdate(doc, meta.snapshot)   // start from the old snapshot
for (const row of pending) Y.applyUpdate(doc, row.update)       // replay the loose updates
const foldedSnapshot = Y.encodeStateAsUpdate(doc)              // re-encode: many → one

await tx.update(documentsTable).set({ snapshot: foldedSnapshot }).where(/* this doc */)
await tx.delete(documentUpdatesTable).where(/* exactly the rows we just folded */)
</code></pre>
<h3>When it runs: the sweep</h3>
<p>Folding on every keystroke would be absurd — re-encoding a 40-page memo because Mara typed one letter. Never folding makes loads crawl. So we fold on a schedule, and only the documents that need it. A repeatable job fires every 60 seconds; each tick is one <strong>sweep</strong>: find the documents whose un-folded log has crossed a threshold, and fold each.</p>
<pre><code class="language-typescript">// src/sync/compaction.ts
export const COMPACTION_THRESHOLD = 200   // fold a document once it has ≥ 200 loose updates
</code></pre>
<p>Because folded rows are deleted, the rows left in <code>document_updates</code> <em>are</em> the un-folded tail — so a plain <code>COUNT</code> per document is exactly "how far behind is this one." The threshold is a tuning knob, not a correctness one: fold more often for a smaller tail and cheaper loads at the cost of more background work, or less often for the reverse. Durability does not depend on it at all.</p>
<p>Now the two subtleties — both real bugs I had to design around.</p>
<h3>The watermark that loses edits</h3>
<p>How does a fold know <em>which</em> rows it folded, so it deletes those and not others? The tempting answer is a high-water mark: store a number <code>snapshotThrough = N</code> meaning "the snapshot folds in every update through seq N." Then load replays <code>seq &gt; N</code>, and a fold deletes <code>seq &lt;= N</code>. One number, clean — and it even <em>looks</em> race-proof, since a new append during a fold gets a seq above N.</p>
<p>It loses edits. <code>seq</code> is a <code>bigserial</code> — Postgres hands out the number at the moment of <strong>insert</strong>, but a row stays invisible to everyone else until its transaction <strong>commits</strong>, and commits do not finish in the order the numbers were handed out. The appends are fire-and-forget, so several are in flight at once:</p>
<pre><code class="language-plaintext">1. Append X grabs seq = 11 — has NOT committed yet
2. Append Y grabs seq = 12 — commits fast
3. Fold reads "seq &gt; snapshotThrough" → sees 12, NOT 11 (uncommitted = invisible). Folds it, sets N = 12
4. Fold deletes seq &lt;= 12. Row 11 is still invisible, so it survives the delete
5. X finally commits seq = 11
6. Next load replays seq &gt; 12 → row 11 is never replayed
</code></pre>
<p>Row 11 is gone — Mara's edit, dropped silently, by the exact layer whose whole job is to never drop one. The bug hides inside the word <em>watermark</em>: <code>seq &lt;= N</code> does <strong>not</strong> mean "I have seen everything up to N." A counter that hands out numbers before transactions commit cannot promise that.</p>
<h3>The fix: delete what you fold, replay what remains</h3>
<p>Throw the watermark away. Two rules, and the loss becomes impossible.</p>
<p><strong>Delete exactly the rows you folded.</strong> The fold reads the loose rows, folds the ones it can see, and remembers their exact <code>seq</code> list. It deletes <em>that list</em> — never <code>seq &lt;= max</code>:</p>
<pre><code class="language-typescript">// src/sync/compaction.ts
const foldedSeqs = pending.map((row) =&gt; row.seq)
await tx.delete(documentUpdatesTable).where(
  and(eq(documentUpdatesTable.documentId, documentId), inArray(documentUpdatesTable.seq, foldedSeqs)),
)
</code></pre>
<p>A row that commits <em>after</em> the read is simply not in the list, so it is never deleted.</p>
<p><strong>Load replays every remaining row</strong> — no <code>seq &gt; N</code> filter. Folded rows are gone, so whatever is still in <code>document_updates</code> <em>is</em> the un-folded tail, a late-committing row 11 included. Run the race again: row 11 commits late, the fold never deleted it (not in the list), and the next load replays every remaining row. Commutativity (Part 1) means replay order doesn't matter, so row 11 lands correctly on top of the snapshot. Nothing lost. "Which rows did I fold?" is answered by holding the actual list for one transaction, not by trusting a single stored number.</p>
<h3>One fold at a time, without blocking writers</h3>
<p>One race is left. Two sweeps fold the <em>same</em> document at the same moment:</p>
<pre><code class="language-plaintext">Sweep A reads loose rows {1, 2, 3}
Sweep B reads loose rows {1, 2, 3, 4}
B writes its snapshot (covers 1–4), deletes rows 1–4
A writes ITS snapshot (covers only 1–3)   ← clobbers B's, and row 4 is already gone
</code></pre>
<p>So two folds of one document must take turns. The tool is a <strong>lock</strong> — a way for one operation to say "I'm using this, wait for me." The catch is that the <em>obvious</em> lock breaks the thing we care about most, and the cleanest way to see why is an analogy.</p>
<p>Picture the <code>documents</code> row as a <strong>house</strong>. It has an <strong>address</strong> — its id, the permanent identity other things point at — and <strong>contents</strong> — the snapshot, the stuff inside that changes. A <strong>fold</strong> rewrites the <em>contents</em>; it never changes the address. An <strong>append</strong> barely touches the house at all: it drops a row in the log that points back at the house by its address (a <em>foreign key</em>), and before it attaches, the database places a light hold meaning <em>"don't demolish this or change its address while I'm linking to it."</em> Many appends can hold that at once.</p>
<p>The reflex lock for the fold grabs the house <em>exclusively</em> — a sign reading <strong>"Renovation in progress. Keep out. The address itself might change."</strong> That makes folds take turns, yes, but now no append can attach, because every append needs the address to stay put. Every keystroke-save stalls behind a <em>background cleanup job</em> — exactly backwards.</p>
<p>But a fold never changes the address, only the contents. So it can hang a more honest sign: <strong>"Rearranging the contents. The address stays exactly the same."</strong> Now a second fold still can't start (two renovators rewriting the same contents would collide), but appends keep flowing — they only ever needed the address to stay put.</p>
<p>Those three signs are real Postgres <strong>row-level locks</strong>, requested by adding a <code>FOR …</code> clause to a read:</p>
<pre><code class="language-plaintext">FOR KEY SHARE       "don't change this row's identity"       ← the append's automatic hold (from the foreign key)
FOR NO KEY UPDATE   "I'll change its contents, not its id"   ← the fold
FOR UPDATE          "I might change anything about it"       ← the greedy one we avoid
</code></pre>
<p><code>FOR NO KEY UPDATE</code> conflicts with <em>itself</em> (two folds serialize) but <strong>not</strong> with <code>FOR KEY SHARE</code> (appends sail past). In code, that is one method on the locking read:</p>
<pre><code class="language-typescript">// src/sync/compaction.ts
const [meta] = await tx
  .select({ snapshot: documentsTable.snapshot })
  .from(documentsTable)
  .where(eq(documentsTable.id, documentId))
  .for('no key update')                      //  → SELECT … FOR NO KEY UPDATE
</code></pre>
<p>The append's <code>FOR KEY SHARE</code> you never write — Postgres takes it automatically because <code>document_updates.document_id</code> is a foreign key referencing <code>documents(id)</code>, so inserting a child row holds the parent's identity still until the link is made. The lock lasts until the transaction's <code>COMMIT</code>. The whole point in one line: <strong>the only thing a fold ever waits on — or makes anything wait on — is another fold.</strong></p>
<hr />
<h2>It works (the proof)</h2>
<p>Two layers of evidence, because "it should converge" is not "it converges."</p>
<p><strong>Automated</strong> — 16 integration tests against the real ws server and real Postgres: bidirectional convergence, same-tick concurrent merge, reconnect → resync → replay, durable cold-reload from Postgres, auth-on-upgrade, and compaction (including a <em>staged commit-reordering race</em> that proves the watermark fix).</p>
<p><strong>Live, in two browser tabs</strong> on one document, the real editor:</p>
<ul>
<li><p>type in Tab A → it renders in Tab B, and back — the full <code>TipTap ↔ Yjs ↔ provider ↔ ws</code> round trip</p>
</li>
<li><p>each tab shows the other's named caret (presence)</p>
</li>
<li><p>reload a tab → content returns and reconnects</p>
</li>
<li><p>I pushed the document to <strong>247 updates</strong>; ~30 seconds later the background sweep folded it live — 247 rows → 0, snapshot written (442 bytes) — exactly the two-tier persistence, end to end</p>
</li>
<li><p>zero app-level console errors</p>
</li>
</ul>
<p>It is roughly 600 lines I own and have debugged.</p>
<hr />
<h2>What this can't do yet: a second server</h2>
<p>One limitation is worth stating plainly, because it is the first thing that breaks under real load.</p>
<p>Everything above assumes <strong>one server process</strong>. Each room — the in-memory <code>Y.Doc</code> for a document — lives in that single process's heap. As long as everyone editing a document connects to the same process, they share the same room, and real-time sync just works.</p>
<p>Now scale out. Put two server processes behind a load balancer. Mara opens the memo and lands on process A; Theo opens the same memo and lands on process B. Each process builds its <em>own</em> room for that document — its own in-memory <code>Y.Doc</code>, its own set of connections — and neither knows the other exists.</p>
<pre><code class="language-plaintext">Mara ──ws──► process A ──► room (Y.Doc) for the memo
Theo ──ws──► process B ──► a DIFFERENT room (Y.Doc) for the same memo
</code></pre>
<p>They are both "connected" and editing the same document, but their keystrokes never reach each other live. A's room broadcasts only to A's connections; B's only to B's. Mara would see Theo's edits only later — on a reload, rebuilt from the shared Postgres log they both append to. For a tool whose entire promise is <em>live</em> collaboration, that is a real hole.</p>
<p>The fix is a later step: <strong>cross-instance fan-out over Redis pub/sub.</strong> Whenever a room produces an update, its process publishes that update to a Redis channel keyed by the document id — and every process subscribed to that channel relays what it hears into its own local room, ignoring its own echoes by an instance id (the self-echo guard, one level up). Redis becomes the bus that makes N separate rooms behave as one.</p>
<p>The shape of the code makes this an addition, not a rewrite: it slots into the exact same <code>doc.on('update')</code> handler where broadcast-and-persist already live —</p>
<pre><code class="language-plaintext">doc.on('update'):
  ├─► broadcast to this process's own connections   (today)
  ├─► append to Postgres                             (today)
  └─► publish to the Redis channel                   (the later step)
</code></pre>
<p>— and the inbound side subscribes to that channel and feeds what it receives back through the same apply-and-broadcast path. Until that lands, the honest statement is this: the implementation is <strong>single-instance</strong>. It is correct and complete for one process, and incomplete the moment you run two.</p>
<hr />
<h2>When I'd reach for a library instead</h2>
<p>I hand-built this to <em>understand</em> it, and that goal is the whole justification. If you don't want to learn the sync layer, Hocuspocus or Liveblocks give you the relay, reconnect, and awareness already debugged — and you should use them.</p>
<p>I'd reach for one the moment the parts I <em>deferred</em> turn expensive: multi-instance fan-out (one process holds each room today; sharing rooms across processes is a Redis pub/sub layer), acknowledged-not-fire-and-forget persistence, a heartbeat for half-open sockets. None of those are hard to see coming; none change the fact that, for learning, hand-rolling was the point.</p>
<p>The whole system in three beats:</p>
<pre><code class="language-plaintext">The CRDT gives every character a permanent name, so concurrent edits merge with no referee.
The relay forwards every update to everyone else and writes down every word.
The append log saves it the instant it happens; compaction folds it so loading stays cheap.
</code></pre>
<p>And the property that ties all three together is the same one: apply the updates in any order, and everyone lands on the same document.</p>
]]></content:encoded></item><item><title><![CDATA[React recursive components]]></title><description><![CDATA[How a recursive JSON editor keeps track of itself
Find the app deployed here and the code here.
The app is two panes.
On the left, you edit a tree of key: value rows.
On the right, the JSON updates as]]></description><link>https://featuringcode.com/react-recursive-components</link><guid isPermaLink="true">https://featuringcode.com/react-recursive-components</guid><category><![CDATA[json]]></category><category><![CDATA[React]]></category><category><![CDATA[Recursion]]></category><category><![CDATA[DND]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sun, 28 Jun 2026 20:02:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/847bdc80-dbe3-4f13-be47-496e477b668f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>How a recursive JSON editor keeps track of itself</h1>
<p>Find the app deployed <a href="https://json-wow.vercel.app/">here</a> and the code <a href="https://github.com/mmswi/json-wow">here</a>.</p>
<p>The app is two panes.</p>
<p>On the left, you edit a tree of <code>key: value</code> rows.</p>
<p>On the right, the JSON updates as you type.</p>
<p>Drag a row to reorder it. The JSON follows. Change a field's type. The JSON follows. Import a file. The whole tree rebuilds.</p>
<p>So here is the question underneath all of it:</p>
<pre><code class="language-plaintext">How does the right side always match the left,
even while you are dragging rows around
and renaming the very keys used to find them?
</code></pre>
<p>There is one more thing to say up front, because it changes how you read everything below.</p>
<p>There is no server.</p>
<p>No backend. No database. No network call anywhere in the flow. Every step in this post runs in your browser, in JavaScript, on the data sitting in memory. When I say "where does this run," the answer is always the same: the client. So the interesting question is not <em>where</em> the work happens — it is <em>what data is canonical, and what is derived from it.</em></p>
<p>That distinction is the whole post.</p>
<hr />
<h2>One tree is the truth. Everything else is computed from it.</h2>
<p>Start with the data, before any of the words.</p>
<p>Here is a small JSON object. We will follow it the whole way through.</p>
<pre><code class="language-json">{
  "name": "Ada",
  "age": 36,
  "address": {
    "city": "London"
  }
}
</code></pre>
<p>Inside the app, that JSON does not exist as JSON.</p>
<p>It exists as a list of nodes. One node per row. Here is the shape of a node:</p>
<pre><code class="language-ts">interface TreeItem {
  id: string          // a stable handle — never appears in the JSON
  key: string
  type: 'string' | 'number' | 'boolean' | 'object'
  value: string | boolean
  children: TreeItem[] // used when type === 'object'
}
</code></pre>
<p>So our example becomes four nodes:</p>
<pre><code class="language-plaintext">TreeItem  id:"a1"  key:"name"     type:"string"  value:"Ada"
TreeItem  id:"a2"  key:"age"      type:"number"  value:"36"
TreeItem  id:"a3"  key:"address"  type:"object"  children:[
  TreeItem  id:"a4"  key:"city"   type:"string"  value:"London"
]
</code></pre>
<p>This list — <code>items: TreeItem[]</code> — lives in a single store.</p>
<p>It is the source of truth.</p>
<p>The JSON on the right is not stored anywhere. It is <em>recomputed</em> from this list, fresh, every time the list changes. Hold onto that. It is the answer to the opening question, and we will earn it properly in a minute.</p>
<p>The data flow, top to bottom:</p>
<pre><code class="language-plaintext">items: TreeItem[]   ← the one source of truth (in memory)
↓
itemsToJson(items)  ← runs on every change
↓
the JSON text you see on the right
</code></pre>
<p>Left is canonical. Right is derived. Nothing on the right is ever the truth.</p>
<hr />
<h2>The id you never see</h2>
<p>Look back at that node shape. The first field is <code>id</code>. It is not part of your JSON. It will never be written to the output. So why is it there?</p>
<p>Because the app constantly has to answer one question:</p>
<pre><code class="language-plaintext">Which node did the user just touch?
</code></pre>
<p>You type in the <code>age</code> field. Something has to find <em>that exact node</em> among all of them and update it. You drag the <code>address</code> row. Something has to know <em>which</em> node moved.</p>
<p>You might reach for the obvious handle: the position in the array. The <code>age</code> node is index 1.</p>
<p>This is bad.</p>
<p>The moment you drag <code>age</code> above <code>name</code>, it is index 0. Its identity changed out from under you, just because it moved.</p>
<p>So maybe use the key. The node <em>is</em> <code>"age"</code>, after all.</p>
<p>This is also bad.</p>
<p>The key is editable — the user can rename it mid-edit. And nothing stops two rows from both being called <code>"age"</code>. An identity that can be renamed or duplicated is not an identity.</p>
<p>So the fix is a separate, stable id that is never shown and never reused for anything else:</p>
<pre><code class="language-ts">export const createItem = (key: string, type: ItemType): TreeItem =&gt; ({
  id: crypto.randomUUID(),
  key,
  type,
  value: DEFAULT_VALUE_FOR_TYPE[type],
  children: [],
})
</code></pre>
<p>Every node gets a <code>crypto.randomUUID()</code> at birth. <code>"a1"</code>, <code>"a2"</code> above are stand-ins for those.</p>
<p>That id does three jobs, all of them about <em>tracking</em>:</p>
<p>It is the React key, so React can tell rows apart across re-renders.</p>
<p>It is the drag identity, so dnd-kit knows which row you grabbed.</p>
<p>It is the lookup key, so an update can find the right node.</p>
<p>The id is the thread the whole app holds onto. The key and the value can change freely; the id never does.</p>
<hr />
<h2>An update, traced end to end</h2>
<p>Let's change <code>age</code> from <code>36</code> to <code>37</code>.</p>
<p>You type in the field. The input fires <code>onChange</code>. The row calls one store action:</p>
<pre><code class="language-ts">updateValue(item.id, event.target.value)   // updateValue("a2", "37")
</code></pre>
<p>Notice what it passes: the <strong>id</strong>, not the position, not the key. The id we just set up. That is the handle in action.</p>
<p>Now the store has to find node <code>"a2"</code> and change it. The nodes are nested — <code>address</code> has a child — so finding one means walking the tree:</p>
<pre><code class="language-ts">const findItem = (items: TreeItem[], id: string): TreeItem | undefined =&gt; {
  for (const item of items) {
    if (item.id === id) return item
    const found = findItem(item.children, id)   // recurse into children
    if (found) return found
  }
  return undefined
}
</code></pre>
<p>Depth-first. Check each node. If it is not a match, descend into its children. Return the first node whose id matches.</p>
<p>What it returns is not a copy. It is the live node, and the action mutates it in place:</p>
<pre><code class="language-ts">updateValue: (id, rawValue) =&gt;
  set((state) =&gt; {
    const item = findItem(state.items, id)
    if (item) item.value = rawValue
  }),
</code></pre>
<p><code>item.value = rawValue</code> looks like it breaks React's "never mutate state" rule.</p>
<p>It does not, because the store runs every change through Immer. You mutate a <em>draft</em>. Immer watches what you touched and produces a new immutable tree with only those nodes replaced. The <code>age</code> node and its ancestors get fresh object references; everything else is reused untouched.</p>
<p>So the update flow is:</p>
<pre><code class="language-plaintext">you type "37"
↓
onChange → updateValue("a2", "37")
↓
findItem walks the tree, returns node a2
↓
mutate the Immer draft: item.value = "37"
↓
Immer produces a new items tree
↓
the store notifies React, the JSON recomputes
</code></pre>
<p>Every store action follows this exact shape. Rename a key, change a type, remove a node — find by id, mutate the draft, let Immer rebuild. The id is always the entry point.</p>
<h3>One detail that looks like a bug and is not</h3>
<p>The <code>age</code> field holds the number 36. But look again at the node:</p>
<pre><code class="language-plaintext">TreeItem  id:"a2"  key:"age"  type:"number"  value:"36"
</code></pre>
<p><code>value:"36"</code>. A string. With quotes. For a number.</p>
<p>This is on purpose.</p>
<p>You might think a number field should store a number. Bind a <code>&lt;input type="number"&gt;</code> to it and be done.</p>
<p>But a controlled number input fights you mid-typing. Try to type <code>51.5</code>. The instant you have typed <code>51.</code>, the value is not yet a valid number, so it gets coerced back to <code>51</code>, and the decimal you were about to type is gone.</p>
<p>So number rows store the user's literal <strong>edit text</strong> — <code>"51."</code> and all — exactly as typed. It stays text the entire time it lives in the tree.</p>
<p>It becomes a real number in exactly one place: the boundary where JSON is generated.</p>
<pre><code class="language-ts">const primitiveJsonValue = (type, value) =&gt; {
  if (type === 'number') {
    const parsed = Number(value)
    return Number.isFinite(parsed) ? parsed : 0   // "37" → 37 here, and only here
  }
  if (type === 'boolean') return value === true
  return typeof value === 'string' ? value : String(value)
}
</code></pre>
<p>So the tree carries text, and the JSON carries a real number. The coercion is a single line, at a single boundary. Everywhere else, <code>"37"</code> is just text, and your typing is never snapped back.</p>
<hr />
<h2>The JSON pane is computed, never stored</h2>
<p>Now we can pay off the promise from the top.</p>
<p>The JSON on the right is not a second copy of your data that the app keeps in sync. Keeping two copies in sync is exactly the bug that makes these things break. There is only one copy — the tree — and the JSON is a pure function of it:</p>
<pre><code class="language-ts">export const itemsToJson = (items: TreeItem[]): JsonObject =&gt; {
  const json = {}
  for (const item of items) {
    json[item.key] = isObjectType(item.type)
      ? itemsToJson(item.children)   // recurse for objects
      : primitiveJsonValue(item.type, item.value)
  }
  return json
}
</code></pre>
<p>Walk the list in order. Each node contributes one <code>key: value</code> pair. Objects recurse into their children. Then <code>JSON.stringify(result, null, 2)</code> turns it into the indented text you read.</p>
<p>The right pane component does only this:</p>
<pre><code class="language-ts">const items = useTreeStore((state) =&gt; state.items)
const jsonText = useMemo(() =&gt; itemsToJsonText(items), [items])
</code></pre>
<p>It subscribes to <code>items</code>. When <code>items</code> changes — and after any update, Immer hands back a new <code>items</code> — <code>useMemo</code> reruns and the text is regenerated.</p>
<p>So the right side cannot drift from the left. It is not synced to the left. It <em>is</em> the left, run through a function.</p>
<p>That is also why expand/collapse never touches the JSON. Which rows are open is view state, kept in a separate <code>collapsedIds</code> map, deliberately outside the tree. <code>itemsToJson</code> never looks at it. You can collapse <code>address</code> to tidy the view, and the output is byte-for-byte identical. Drawing state and data state never cross.</p>
<hr />
<h2>How a row renders itself</h2>
<p>The tree on screen has the same recursive shape as the data, because the component renders itself.</p>
<p><code>TreeItemRow</code> draws one node: its drag handle, its key input, its type dropdown, and its value editor. Then, if the node is an object, it renders a <code>TreeItemRow</code> for each child — which renders <em>its</em> children, and so on, as deep as the data goes.</p>
<pre><code class="language-tsx">{isObject &amp;&amp; isExpanded &amp;&amp; (
  &lt;ul className="tree-list"&gt;
    {item.children.map((child) =&gt; (
      &lt;TreeItemRow key={child.id} item={child} parentId={item.id} /&gt;
    ))}
  &lt;/ul&gt;
)}
</code></pre>
<p>Two things ride along in that one line, and both matter later.</p>
<p><code>key={child.id}</code> — the id again, now as React's reconciliation key. It is how React keeps each DOM row matched to its node across every re-render and reorder.</p>
<p><code>parentId={item.id}</code> — every row is told who its parent is. A root row gets <code>parentId={null}</code>. This breadcrumb is what makes drag-and-drop safe, which is the next section.</p>
<p>Each row reaches into the store for only the slices it needs:</p>
<pre><code class="language-tsx">const updateKey = useTreeStore((state) =&gt; state.updateKey)
const removeItem = useTreeStore((state) =&gt; state.removeItem)
const isCollapsed = useTreeStore((state) =&gt; Boolean(state.collapsedIds[item.id]))
</code></pre>
<p>It does not receive a giant prop bundle from its parent and it does not hold the whole tree. It takes its one <code>item</code>, subscribes to the actions and the sliver of state it uses, and renders. The recursion gives you the shape; the store gives every row direct access to the truth.</p>
<pre><code class="language-plaintext">App
↓
JsonTreeEditor (left)        JsonCodeView (right)
↓                            ↓
TreeItemRow (name)           itemsToJson(items)
TreeItemRow (age)            ↓
TreeItemRow (address)        the JSON text
  └ TreeItemRow (city)
</code></pre>
<hr />
<h2>Drag and drop: dnd-kit moves nothing</h2>
<p>Here is the part that surprises people.</p>
<p>You drag the <code>address</code> row to a new spot. You might think dnd-kit picked up the node and moved it in your data.</p>
<p>It did not. dnd-kit does not touch your state at all.</p>
<p>All it does is watch the pointer and, when you let go, hand you two ids: the row you picked up, and the row you dropped it over.</p>
<pre><code class="language-ts">const handleDragEnd = (event: DragEndEvent) =&gt; {
  const { active, over } = event
  if (!over || active.id === over.id) return
  // ...
}
</code></pre>
<p><code>active.id</code> is the row you grabbed. <code>over.id</code> is the row you released onto. That is the entire gift from dnd-kit. Moving the node in your own state is <em>your</em> job — and because the truth is one tree keyed by id, you already have everything you need to do it.</p>
<p>But first, a guardrail. Remember the <code>parentId</code> breadcrumb every row was given? dnd-kit carries it in the drag payload, so when a drag ends, you can read the parent of both rows:</p>
<pre><code class="language-ts">const activeParentId = (active.data.current?.parentId ?? null)
const overParentId = (over.data.current?.parentId ?? null)
if (activeParentId !== overParentId) return     // different parents → ignore
</code></pre>
<p>If you try to drag <code>city</code> (inside <code>address</code>) out to sit next to <code>name</code> (at the root), their parents differ, and the drag is dropped. Reordering is allowed only among true siblings — rows that share a parent. That keeps the operation a simple reshuffle of one list, never a re-parenting that would have to splice a node out of one place and into another.</p>
<p>When the parents match, the move is one store action:</p>
<pre><code class="language-ts">reorderSiblings: (parentId, activeId, overId) =&gt;
  set((state) =&gt; {
    const list = childListFor(state.items, parentId)   // the sibling array
    const from = list.findIndex((item) =&gt; item.id === activeId)
    const to = list.findIndex((item) =&gt; item.id === overId)
    const nothingToMove = from === -1 || to === -1 || from === to
    if (nothingToMove) return
    const reordered = arrayMove(list, from, to)
    list.splice(0, list.length, ...reordered)          // write order back into the draft
  }),
</code></pre>
<p>Find the right sibling list — the root list if <code>parentId</code> is null, otherwise that object's <code>children</code>. Find where the dragged node is and where it landed, <em>by id</em>. <code>arrayMove</code> returns the list in the new order. Splice that order back into the Immer draft.</p>
<p>Immer rebuilds the tree. The JSON recomputes. The key order in the output now matches the new row order, because <code>itemsToJson</code> walks the list in exactly the order the list is in.</p>
<p>The full drag flow:</p>
<pre><code class="language-plaintext">you drop the row
↓
dnd-kit: "active=a3 ended over=a1"   (ids only — no data changed)
↓
same parent? if not, stop
↓
reorderSiblings(parent, "a3", "a1")
↓
arrayMove on the sibling list, written into the Immer draft
↓
new items tree → JSON recomputes in the new order
</code></pre>
<p>dnd-kit ran the gesture. The id told you which node it was. Your store did the move. The same division of labor as every other update.</p>
<hr />
<h2>Import: untrusted text becomes a trusted tree</h2>
<p>Last flow. You paste JSON, or pick a <code>.json</code> file. The text has to become a tree of nodes — with ids, with types — before the editor can show it.</p>
<p>You might think you can hand the parsed object straight to the components. You cannot. Parsed JSON has no ids, so nothing can be tracked or dragged. And it is untrusted — it might not even be an object.</p>
<p>So import runs through one careful door:</p>
<pre><code class="language-ts">export const parseJsonToItems = (text: string): ParseResult =&gt; {
  let parsed: unknown
  try {
    parsed = JSON.parse(text)
  } catch (error: unknown) {
    return { ok: false, error: error instanceof Error ? error.message : 'Invalid JSON' }
  }
  const isJsonObject =
    parsed !== null &amp;&amp; typeof parsed === 'object' &amp;&amp; !Array.isArray(parsed)
  if (!isJsonObject) {
    return { ok: false, error: 'Top level must be a JSON object.' }
  }
  return { ok: true, items: jsonToItems(parsed) }
}
</code></pre>
<p>Three gates, in order:</p>
<p>Parse it. If <code>JSON.parse</code> throws, return the error message — never let it crash the app.</p>
<p>Check the top level is a plain object. Not <code>null</code>, not an array. The editor renders <code>key: value</code> rows, and only an object has those.</p>
<p>Hand the clean object to <code>jsonToItems</code>, which builds the tree.</p>
<p>Note the return type: a <code>ParseResult</code> that is either <code>{ ok: true, items }</code> or <code>{ ok: false, error }</code>. Errors come back as values, not exceptions, so the import panel can show "Top level must be a JSON object." in red instead of blowing up.</p>
<p>Building the tree is the mirror image of generating the JSON. Walk the object's entries; turn each into a node:</p>
<pre><code class="language-ts">export const jsonToItems = (json: JsonObject): TreeItem[] =&gt;
  Object.entries(json).map(([key, value]) =&gt; itemFromEntry(key, value))

const itemFromEntry = (key: string, value: JsonValue): TreeItem =&gt; {
  const type = itemTypeForValue(value)
  const item = createItem(key, type)          // ← fresh id minted here
  if (isPlainObject(value)) {
    item.children = jsonToItems(value)         // recurse for nested objects
    return item
  }
  if (type === 'boolean') { item.value = value; return item }
  item.value = primitiveTextFor(value)         // numbers land as text, e.g. 36 → "36"
  return item
}
</code></pre>
<p>Three things worth catching here:</p>
<p><code>createItem</code> mints a fresh <code>crypto.randomUUID()</code> for every node. The imported data is fully tracked and draggable the instant it loads — same id machinery as everything else.</p>
<p>Nested objects recurse, so an imported <code>address</code> rebuilds its <code>city</code> child to any depth.</p>
<p>A number like <code>36</code> is stored as the text <code>"36"</code>, exactly the convention from earlier — so an imported number behaves identically to one you typed.</p>
<p>And the edges? <code>null</code> and arrays are not among the four supported types, so rather than throw, they are kept as readable text via <code>JSON.stringify</code> and shown as string rows. Import never crashes on a value it does not model.</p>
<p>Once <code>parseJsonToItems</code> returns <code>ok</code>, the import control hands the new tree to the store:</p>
<pre><code class="language-ts">setItems: (items) =&gt;
  set((state) =&gt; {
    state.items = items
    state.collapsedIds = {}   // a new document starts fully expanded
  }),
</code></pre>
<p>The truth is replaced wholesale. The JSON pane, being derived, redraws itself the next render. No syncing. The new tree simply <em>is</em> the new truth.</p>
<pre><code class="language-plaintext">pasted text
↓
JSON.parse           (reject on syntax error)
↓
top-level object?    (reject arrays, null, primitives)
↓
jsonToItems          (mint ids, recurse, numbers → text)
↓
setItems             (replace the truth)
↓
JSON pane recomputes from the new tree
</code></pre>
<hr />
<h2>The tradeoff, honestly</h2>
<p>One source of truth that everything derives from is simple, and it is why nothing drifts. But it is not free, and it is not always what you want.</p>
<p>The JSON regenerates on <strong>every keystroke</strong>. The whole tree is walked each time. For a human-scale document that is instant and you will never feel it. For a 10,000-node tree, recomputing all of it on every character would be the first thing to fix — you would memoize per-subtree, or debounce the text generation.</p>
<p>And the model is deliberately small. Four types: string, number, boolean, object. No arrays. No <code>null</code> as a first-class value. That keeps the node shape and every conversion trivial to reason about. If you needed real arrays and round-trip-perfect editing of every JSON value, the tree would need a richer type, and several of the clean one-liners above would grow.</p>
<p>So this design is the right answer for an editor you can hold in your head. It is the wrong answer for a million-node document or a faithful JSON-spec editor. Knowing which you are building is the actual decision.</p>
<hr />
<p>The id tracks every node, no matter how it is renamed or moved.</p>
<p>Immer rebuilds the one tree on every change.</p>
<p>And the JSON is never stored — only ever computed back from the truth.</p>
]]></content:encoded></item><item><title><![CDATA[New react callbacks memoization pattern]]></title><description><![CDATA[How to stabilise a memo component when everything else fails
memo looks simple until you wrap a component in it and it re-renders anyway.
So here is the question underneath this whole pattern:
How do ]]></description><link>https://featuringcode.com/new-react-callbacks-memoization-pattern</link><guid isPermaLink="true">https://featuringcode.com/new-react-callbacks-memoization-pattern</guid><category><![CDATA[React]]></category><category><![CDATA[memo]]></category><category><![CDATA[caching]]></category><category><![CDATA[Memoization]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sun, 28 Jun 2026 14:37:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/7584cd13-599b-40c2-832d-8da5ffc4a8c0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>How to stabilise a memo component when everything else fails</h1>
<p><code>memo</code> looks simple until you wrap a component in it and it re-renders anyway.</p>
<p>So here is the question underneath this whole pattern:</p>
<pre><code class="language-plaintext">How do you give a memoized child function props that never change identity,
when the parent rebuilds those functions on every render
and you can't refactor the parent to stop?
</code></pre>
<p>Let me trace it with one example, all the way through.</p>
<h2>The running example</h2>
<p>A <code>&lt;DataGrid&gt;</code> that renders thousands of cells. Expensive. A perfect <code>memo</code> candidate.</p>
<p>Its parent owns the state and passes handlers down:</p>
<pre><code class="language-tsx">const Page = () =&gt; {
  const [query, setQuery] = useState('')
  const [selected, setSelected] = useState&lt;Row[]&gt;([])

  return (
    &lt;&gt;
      &lt;input value={query} onChange={(e) =&gt; setQuery(e.target.value)} /&gt;

      &lt;DataGrid
        rows={filterRows(query)}
        selected={selected}
        onSelect={(row) =&gt; setSelected([...selected, row])}
        onClearSelection={() =&gt; setSelected([])}
        onDeleteSelected={() =&gt; deleteRows(selected)}
        onSortColumn={(col) =&gt; sortBy(col)}
      /&gt;
    &lt;/&gt;
  )
}

const DataGrid = memo((props: Props) =&gt; {
  // thousands of cells
})
</code></pre>
<p>The handlers are not independent.</p>
<p><code>onSelect</code> reads and replaces <code>selected</code>.</p>
<p><code>onClearSelection</code> replaces <code>selected</code>.</p>
<p><code>onDeleteSelected</code> reads <code>selected</code>.</p>
<p>They are interconnected. They all close over the same state. Hold onto that — it is what breaks the easy fix.</p>
<h2>Watch memo fail</h2>
<p><code>memo</code> compares the new props to the old props before re-rendering.</p>
<p>Shallowly. Key by key. With <code>Object.is</code>.</p>
<p>Same reference for every prop → it skips the render.</p>
<p>One new reference → it renders.</p>
<p>Type a single character in the search box:</p>
<pre><code class="language-plaintext">keystroke
↓
setQuery
↓
Page re-renders
↓
every inline arrow is created fresh: onSelect, onClearSelection, onDeleteSelected, onSortColumn
↓
memo compares: onSelect !== last onSelect
↓
DataGrid re-renders all of its cells
</code></pre>
<p>A function created inline is a new object every render.</p>
<p><code>(row) =&gt; ...</code> today is not <code>Object.is</code>-equal to <code>(row) =&gt; ...</code> yesterday.</p>
<p>The shallow compare fails on the first callback it checks.</p>
<p>The expensive render runs anyway.</p>
<h2>Why useCallback can't save this one</h2>
<p>The textbook fix is <code>useCallback</code>: it hands back the same function object between renders, so the comparison passes.</p>
<p>Wrap <code>onSelect</code>:</p>
<pre><code class="language-tsx">const onSelect = useCallback(
  (row) =&gt; setSelected([...selected, row]),
  [selected]
)
</code></pre>
<p>The callback closes over <code>selected</code>, so <code>selected</code> goes in the dependency array.</p>
<p>Which means <code>onSelect</code> becomes a new object every time <code>selected</code> changes.</p>
<p>Which is every time you select a row.</p>
<p><code>memo</code> breaks again, for the exact reason it was breaking before.</p>
<p>Drop the dependency instead:</p>
<pre><code class="language-tsx">const onSelect = useCallback(
  (row) =&gt; setSelected([...selected, row]),
  [] // stable, but now wrong
)
</code></pre>
<p>The reference is stable now. But the closure is frozen at the first render.</p>
<p><code>selected</code> is always the empty array it was on mount.</p>
<p>Selecting a second row throws away the first.</p>
<p>That is a bug.</p>
<p>This is the trap:</p>
<pre><code class="language-plaintext">Put the deps in   → the reference changes → memo breaks.
Leave the deps out → the closure goes stale → bug.
</code></pre>
<p><code>useCallback</code> alone cannot give you a stable reference <em>and</em> fresh values.</p>
<p>These are just some shallow examples to make a point, but imagine that you have some thousands of lines of code and over thirty interconnected handlers with lots of props themselves. To fix the grid you'd have to get the dependency arrays right across all of them, keep them in sync as the code changes, and probably lift state or thread it through reducers.</p>
<p>That is a real refactor of the parent which I'm not against, but sometime in the real world, you don't have the time to do this.</p>
<h2>The escape is a ref</h2>
<p>You need one thing the trap denies you: a value that is <strong>stable in identity</strong> but <strong>reads fresh logic</strong>.</p>
<p>A function whose reference never changes.</p>
<p>That, when called, runs the newest version of the code.</p>
<p>A plain variable can't do that. <code>useCallback</code> can't do that.</p>
<p>A ref can.</p>
<p>A ref is a box with a stable identity whose contents you can swap.</p>
<p>So:</p>
<pre><code class="language-plaintext">Hand the child a function that never changes.
Have that function read the real callback out of a ref at call time.
Update the ref to the latest props on every render.
</code></pre>
<p>The child sees a frozen reference.</p>
<p>The ref always holds the latest logic.</p>
<p>The two are decoupled.</p>
<h2>The hook</h2>
<p><code>useStablePropsCallbacks</code> does this for every function prop at once:</p>
<pre><code class="language-tsx">import { useRef } from 'react'

type AnyCallback = (...args: unknown[]) =&gt; unknown

// A function of any shape — only used to detect which props are callbacks.
type AnyFunction = (...args: any[]) =&gt; any

// The prop names in P whose value is a function.
// Map each key to itself-or-never, then `[keyof P]` collapses it to the union of survivors.
type CallbackKeys&lt;P&gt; = {
  [K in keyof P]: P[K] extends AnyFunction ? K : never
}[keyof P]

// P with only those props kept.
type CallbacksOf&lt;P&gt; = Pick&lt;P, CallbackKeys&lt;P&gt;&gt;

const isCallbackProp = &lt;P extends object&gt;(
  propKey: string,
  props: P
): propKey is string &amp; keyof CallbacksOf&lt;P&gt; =&gt;
  typeof (props as Record&lt;string, unknown&gt;)[propKey] === 'function'

export const useStablePropsCallbacks = &lt;P extends object&gt;(
  props: P
): CallbacksOf&lt;P&gt; =&gt; {
  // Refreshed every render so the proxies reach the newest callbacks.
  const latestPropsRef = useRef(props)
  latestPropsRef.current = props

  // The proxy object. Built once, never rebuilt.
  const stableCallbacksRef = useRef&lt;CallbacksOf&lt;P&gt; | null&gt;(null)

  if (!stableCallbacksRef.current) {
    const proxies = {} as Record&lt;string, AnyCallback&gt;

    for (const propKey of Object.keys(props)) {
      if (!isCallbackProp(propKey, props)) continue

      proxies[propKey] = (...args: unknown[]) =&gt; {
        const latestProps = latestPropsRef.current as Record&lt;string, unknown&gt;
        const latestCallback = latestProps[propKey] as AnyCallback
        return latestCallback(...args)
      }
    }

    stableCallbacksRef.current = proxies as CallbacksOf&lt;P&gt;
  }

  return stableCallbacksRef.current
}
</code></pre>
<p><code>CallbacksOf&lt;P&gt;</code> is just "<code>P</code> with the non-function props removed" — it types the return value as only the callbacks. The runtime hook is three moving parts.</p>
<p><code>latestPropsRef</code> holds the latest props. <code>latestPropsRef.current = props</code> runs on every render, so the box always contains the newest callbacks. (Writing a ref during render is normally a yellow flag; it is safe here because the proxies only ever read "whatever is latest" — they never snapshot a value at a particular render.)</p>
<p><code>stableCallbacksRef</code> holds the proxy object. The <code>if (!stableCallbacksRef.current)</code> block runs only on the first render. After that the same object comes back forever.</p>
<p><strong>Each proxy</strong> is, for one function prop, a wrapper that resolves the real callback at call time: read the latest props, pick the callback for this key, forward the args. Its identity is fixed for the life of the component, but the function it forwards to is always the current one.</p>
<h2>Wiring it in</h2>
<p>The hook returns stable versions of the function props. Spread them over the real props so they win:</p>
<pre><code class="language-tsx">const StableDataGrid = (props: Props) =&gt; {
  const stableCallbacks = useStablePropsCallbacks(props)
  return &lt;DataGrid {...props} {...stableCallbacks} /&gt;
}
</code></pre>
<p>Order matters. <code>{...props}</code> puts the raw, unstable callbacks in first. <code>{...stableCallbacks}</code> then overrides each function prop with its stable proxy.</p>
<p>A thin outer wrapper stabilizes the references. The memoized inner component receives the stable ones.</p>
<h2>The flow, end to end</h2>
<p>Type a character in the search box again. This time:</p>
<pre><code class="language-plaintext">keystroke
↓
setQuery → Page re-renders → new inline callbacks (as before)
↓
StableDataGrid runs
↓
latestPropsRef.current = props          (every render — ref now holds the new callbacks)
↓
proxy object returned                   (same object as last render — built once at mount)
↓
DataGrid receives the same proxy references it saw last time
↓
memo shallow compare passes → DataGrid skips its render
↓
later, user clicks a row → proxy onSelect fires
↓
proxy reads latestPropsRef.current.onSelect → runs the newest closure → fresh `selected`
</code></pre>
<p>The expensive grid stayed put through every keystroke.</p>
<p>When a real interaction happened, the call hit the current logic, with the current state.</p>
<p>Stable identity for <code>memo</code>. Fresh closure for correctness. No <code>useCallback</code>, no dependency arrays, no parent refactor.</p>
<h2>What this gives up</h2>
<p><strong>The callback set is frozen at mount.</strong> <code>Object.keys(props)</code> runs once, inside the <code>if</code> block. A handler that is <code>undefined</code> on the first render and supplied later never gets a proxy. If your function props are all present from the start, you are fine. If they appear conditionally, they slip through.</p>
<p><strong>Only function props are stabilized.</strong> Object and array props still get new references when the parent recreates them, and will still break <code>memo</code>. Those need their own handling.</p>
<p><strong>The indirection costs you some tooling.</strong> Stack traces pass through the proxy. And because the wiring is dynamic, the <code>react-hooks/exhaustive-deps</code> lint can't see it — you trade a class of dependency-array bugs for a class of "invisible to the linter" ones.</p>
<h2>When not to reach for it</h2>
<p>One or two callbacks? Just use <code>useCallback</code>. It is clearer, and the linter helps you.</p>
<p>This pattern earns its keep in one narrow situation: many interconnected, stateful callbacks, on a genuinely expensive child, where rewriting the parent to hand-stabilize each one isn't worth it.</p>
<p>Inside that window, it is the difference between <code>memo</code> working and <code>memo</code> being decoration. Outside it, it is indirection you don't need.</p>
<p>The whole thing in three beats:</p>
<pre><code class="language-plaintext">The proxy holds a stable identity.
The ref holds the latest logic.
The memo holds its ground.
</code></pre>
<hr />
<h1>Appendix: the types, traced one step at a time</h1>
<p>Three lines of types do the heavy lifting in that hook, and they are where most eyes glaze over. Mine did.</p>
<pre><code class="language-ts">type CallbackKeys&lt;P&gt; = {
  [K in keyof P]: P[K] extends AnyFunction ? K : never
}[keyof P]

type CallbacksOf&lt;P&gt; = Pick&lt;P, CallbackKeys&lt;P&gt;&gt;

const isCallbackProp = &lt;P extends object&gt;(
  propKey: string,
  props: P
): propKey is string &amp; keyof CallbacksOf&lt;P&gt; =&gt;
  typeof (props as Record&lt;string, unknown&gt;)[propKey] === 'function'
</code></pre>
<p>The <code>P</code> is the part that makes it abstract. But <code>P</code> is not a real type — it is a placeholder. A fill-in-the-blank.</p>
<p>It works exactly like a function parameter, one level up. A function parameter stands for whatever <em>value</em> you pass in. <code>P</code> is a <em>type</em> parameter: it stands for whatever <em>type</em> you pass in.</p>
<p>And you pass a type in by writing it in the angle brackets. <code>CallbacksOf&lt;Props&gt;</code> hands <code>Props</code> to the blank called <code>P</code>. From that point on, TypeScript substitutes <code>Props</code> for <code>P</code> everywhere in the definition — <code>keyof P</code> becomes <code>keyof Props</code>, <code>P[K]</code> becomes <code>Props[K]</code>, <code>Pick&lt;P, …&gt;</code> becomes <code>Pick&lt;Props, …&gt;</code>.</p>
<p>Here is the type we fill it with:</p>
<pre><code class="language-ts">type Props = {
  selected: Row[]
  onSelect: (row: Row) =&gt; void
  onClearSelection: () =&gt; void
  count: number
}
</code></pre>
<p>Two functions. Two non-functions. We want a type that keeps only the two functions.</p>
<p>For every example below, the rule is simple: wherever a definition says <code>P</code>, read <code>Props</code>.</p>
<p>One thing to hold in your head before we start: none of this runs.</p>
<p>These types are erased before your code executes. There is no <code>CallbackKeys</code> object at runtime, no loop, no work. This is the compiler reasoning about shapes — all of it happens in your editor and at build time, then vanishes. So when I say "the value becomes a name," I mean in the type, on the compiler's scratch pad. Nothing is computed when the app runs.</p>
<h2>CallbackKeys: filtering keys when the language has no filter</h2>
<p>You'd think you could just write <code>keyof Props</code>.</p>
<p><code>keyof Props</code> gives you the names of every prop, as a union:</p>
<pre><code class="language-plaintext">"selected" | "onSelect" | "onClearSelection" | "count"
</code></pre>
<p>But that is all four. We only want the two that are functions.</p>
<p>And here is the problem: TypeScript has no <code>.filter()</code> for keys. You cannot say "keep the keys where the value is a function." There is no such operator.</p>
<p>So you do it sideways. In three moves.</p>
<p><strong>Move 1 — walk every key and replace its value.</strong></p>
<pre><code class="language-ts">{
  [K in keyof P]: P[K] extends AnyFunction ? K : never
}
</code></pre>
<p><code>[K in keyof P]</code> means "for each key <code>K</code> in <code>Props</code>, make an entry."</p>
<p>The value of each entry is not the original type. It is <code>P[K] extends AnyFunction ? K : never</code> — a question asked per key:</p>
<pre><code class="language-plaintext">Is this prop's value a function?
Yes → put the key's own name as the value.
No  → put `never` as the value.
</code></pre>
<p>So the object type you get is:</p>
<pre><code class="language-ts">{
  selected: never                          // Row[] is not a function
  onSelect: "onSelect"                      // a function → its own name
  onClearSelection: "onClearSelection"      // a function → its own name
  count: never                              // number is not a function
}
</code></pre>
<p>Read that again. The function keys now hold their <em>own name</em> as a value. The rest hold <code>never</code>.</p>
<p><strong>Move 2 — read all the values at once.</strong></p>
<p><code>[keyof P]</code> on the end looks like indexing an array. It is indexing a type.</p>
<p>When you index an object type by a single key, you get that key's value. When you index it by a <em>union</em> of keys, you get the union of all those values.</p>
<p><code>keyof Props</code> is the union of all four keys, so:</p>
<pre><code class="language-plaintext">{…}["selected" | "onSelect" | "onClearSelection" | "count"]
</code></pre>
<p>hands back the union of the four values:</p>
<pre><code class="language-plaintext">never | "onSelect" | "onClearSelection" | never
</code></pre>
<p><strong>Move 3 —</strong> <code>never</code> <strong>falls out for free.</strong></p>
<p><code>never</code> is the empty type. In a union it just disappears — <code>never | "onSelect"</code> is <code>"onSelect"</code>.</p>
<p>So the whole thing collapses to:</p>
<pre><code class="language-plaintext">"onSelect" | "onClearSelection"
</code></pre>
<p>That is <code>CallbackKeys&lt;Props&gt;</code>. The names of the function props, and nothing else.</p>
<p>The flow, end to end:</p>
<pre><code class="language-plaintext">Props
↓ keyof P
"selected" | "onSelect" | "onClearSelection" | "count"
↓ map each key → (its own name if a function, else never)
{ selected: never, onSelect: "onSelect", onClearSelection: "onClearSelection", count: never }
↓ [keyof P] — read every value as one union
never | "onSelect" | "onClearSelection" | never
↓ never drops out of a union
"onSelect" | "onClearSelection"
</code></pre>
<p>The map-to-name-then-index flow <em>is</em> the missing <code>filter</code>. That is the only reason it exists.</p>
<h2>CallbacksOf: turning the names back into an object</h2>
<p>Now you have the names. You want the object.</p>
<pre><code class="language-ts">type CallbacksOf&lt;P&gt; = Pick&lt;P, CallbackKeys&lt;P&gt;&gt;
</code></pre>
<p><code>Pick&lt;T, K&gt;</code> is built into TypeScript. It means exactly what it says: pick keys <code>K</code> out of object <code>T</code>, keep their types, drop everything else.</p>
<p>So <code>Pick&lt;Props, "onSelect" | "onClearSelection"&gt;</code> is:</p>
<pre><code class="language-ts">{
  onSelect: (row: Row) =&gt; void
  onClearSelection: () =&gt; void
}
</code></pre>
<p><code>Props</code> with the non-functions gone. The values come through untouched.</p>
<p>That is <code>CallbacksOf&lt;Props&gt;</code>. One readable line, because <code>CallbackKeys</code> already did the hard part.</p>
<h2>isCallbackProp: a runtime check that teaches the compiler something</h2>
<p>This one mixes two worlds, which is what makes it look strange.</p>
<pre><code class="language-ts">const isCallbackProp = &lt;P extends object&gt;(
  propKey: string,
  props: P
): propKey is string &amp; keyof CallbacksOf&lt;P&gt; =&gt;
  typeof (props as Record&lt;string, unknown&gt;)[propKey] === 'function'
</code></pre>
<p>The two worlds are both in the signature, and they are easy to mix up because they look alike.</p>
<p><code>props</code> — lowercase — is the real object your code holds at runtime: the actual <code>{ selected, onSelect, … }</code>.</p>
<p><code>P</code> — uppercase — is its <em>type</em>: <code>Props</code>, the same thing we filled in earlier.</p>
<p><code>props: P</code> just says "this value has that shape." Lowercase is the value, uppercase is the shape of the value. So in our example, <code>props</code> is an object and <code>P</code> is <code>Props</code>.</p>
<p>Ignore the return type for a second. The body is trivial:</p>
<pre><code class="language-plaintext">typeof props[propKey] === 'function'
</code></pre>
<p>"Is the value at this key a function?" A plain boolean. That part runs at runtime, in the browser, while the hook builds its proxies.</p>
<p>Now the strange part: the return type is not <code>boolean</code>. It is <code>propKey is string &amp; keyof CallbacksOf&lt;P&gt;</code>.</p>
<p>That is a <strong>type predicate</strong>. It changes what the compiler knows about <code>propKey</code> after the call.</p>
<p>Here is why you need it. Inside the hook:</p>
<pre><code class="language-ts">for (const propKey of Object.keys(props)) {
  if (!isCallbackProp(propKey, props)) continue
  // ...
}
</code></pre>
<p><code>Object.keys</code> returns <code>string[]</code>. So <code>propKey</code> starts as a plain <code>string</code>. The compiler has no idea <em>which</em> key it is — it could be <code>"count"</code>, which is not a callback.</p>
<p>A naive version returning <code>: boolean</code> would check the value at runtime, but the compiler would still see <code>propKey</code> as a plain <code>string</code> afterward. It would not let you treat it as a callback key.</p>
<p>The <code>is</code> predicate fixes that. It tells the compiler:</p>
<pre><code class="language-plaintext">If this function returns true, then for the code that follows,
you may treat `propKey` as `string &amp; keyof CallbacksOf&lt;P&gt;`.
</code></pre>
<p>For our <code>Props</code>, <code>keyof CallbacksOf&lt;Props&gt;</code> is <code>"onSelect" | "onClearSelection"</code>. So after the <code>if</code>, the compiler narrows <code>propKey</code> from "some string" to "one of the callback names." Now indexing the callbacks object with it is safe.</p>
<p>The <code>string &amp;</code> part is a small guard. <code>keyof</code> can in general yield <code>string | number | symbol</code>, but <code>Object.keys</code> only ever gives strings — intersecting with <code>string</code> keeps the result a string key. For our <code>Props</code> the keys are already strings, so it changes nothing here; it matters only for the fully generic <code>P</code>.</p>
<p>Now the honest part, because a predicate has a sharp edge.</p>
<p>The body runs. The predicate does not.</p>
<p><code>typeof … === 'function'</code> is real work the browser does. <code>propKey is …</code> is a <em>promise you make to the compiler</em>, and the compiler believes it without checking. If you wrote a boolean that did not actually match the promise, TypeScript would trust the lie and narrow anyway.</p>
<p>Here the boolean and the promise agree — a prop whose value is a function genuinely is a callback key — so it is sound. But that is on you to get right, not something the compiler proves.</p>
<p>The three types in three beats:</p>
<pre><code class="language-plaintext">keyof P lists every prop name.
CallbackKeys filters those names down to the callbacks.
isCallbackProp proves, at runtime, that one loose string is really one of them.
</code></pre>
]]></content:encoded></item><item><title><![CDATA[2FA - the gold standard]]></title><description><![CDATA[After going through auth core of my pet project (redline) — sessions, login, Google sign-in, email verification, password reset — built on vetted primitives and explained line by line so a human could]]></description><link>https://featuringcode.com/2fa-the-gold-standard</link><guid isPermaLink="true">https://featuringcode.com/2fa-the-gold-standard</guid><category><![CDATA[2FA]]></category><category><![CDATA[Security]]></category><category><![CDATA[authentication]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Thu, 25 Jun 2026 17:01:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/b2ed7593-4e83-4d29-a607-9cf850482645.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>After going through auth <em>core</em> of my pet project (redline) — sessions, login, Google sign-in, email verification, password reset — built on vetted primitives and explained line by line so a human could actually follow it. You can read <a href="https://featuringcode.com/auth-from-scratch">that one</a> first; this picks up where it left off. The feature lives <a href="https://github.com/mmswi/miniSocialApp/tree/feature/auth">here</a>, and if you want it commit by commit, the <code>explanatory-docs/2FA/</code> folder explains each increment.</p>
<p>This post is about the <em>second factor</em>. The thing where Mara scans her face to finish logging in. It's built on one more vetted package — <a href="https://www.npmjs.com/package/@simplewebauthn/server"><code>@simplewebauthn</code></a> for the passkey crypto — and everything around it is hand-rolled so you can see the seams.</p>
<p>Same method as last time: trace one person through the whole machine.</p>
<p>Mara already has an account. She signs up, logs in, signs in with Google, resets her password — all of that is the first post. Now she wants a second lock on her account. She turns it on. She logs in with it. She loses her phone and gets back in with a recovery code. She buys a laptop and adds a second key. Eventually she turns the whole thing off.</p>
<p>Follow Mara, and the rest falls into place.</p>
<p>Three themes carry the whole post, so watch for them:</p>
<ul>
<li><p><strong>Nothing secret ever travels.</strong> A password is a shared secret — it has to cross the wire to be checked. A passkey isn't. The thing that proves Mara's identity never leaves her phone, not even to the server. That single property is what makes it un-phishable, and it reshapes everything downstream.</p>
</li>
<li><p><strong>The server always aims the second factor.</strong> The dangerous bug in 2FA isn't a weak signature — it's letting the client say <em>whose</em> second factor this is. Get that wrong and the first factor becomes decorative. The fix is one rule, enforced in four places.</p>
</li>
<li><p><strong>The best defenses are holes you never built.</strong> Like last time. A whole class of attack keeps turning out to be impossible because of the <em>shape</em> of the thing, not because of a check we added.</p>
</li>
</ul>
<p>Let's go.</p>
<hr />
<h2>First: what a passkey actually is</h2>
<p>Here are the scary words, all at once:</p>
<pre><code class="language-plaintext">Passkey.   WebAuthn.   Relying Party.   Challenge.   Attestation.   Assertion.
</code></pre>
<p>And the usual explanation: <em>"the authenticator signs a challenge with a private key, and the server verifies it with the public key."</em></p>
<p>That is not an explanation. That is a list of things you are now supposed to understand.</p>
<p>So here is the real question underneath all of it:</p>
<pre><code class="language-plaintext">How can Mara prove it's really her using her face —
when her face never leaves her phone, and in a way a fake website can't copy and replay?
</code></pre>
<h3>The naive second factor, and why it's phishable</h3>
<p>The obvious way to add a second factor is a shared secret.</p>
<p>A password is a shared secret. A TOTP code — the 6 digits in Google Authenticator — is a shared secret too. The server and the phone both know it, and the phone just shows the current code to Mara to type in.</p>
<pre><code class="language-plaintext">Mara's phone:   secret = 7Q2F...  →  shows code  →  482913
redline server: secret = 7Q2F...  →  expects     →  482913
</code></pre>
<p>It works. It is also phishable.</p>
<p>A secret only works if it <em>travels</em>. Mara reads the code off her screen and types it into a page. So picture a fake site — <code>red1ine.com</code> as a pixel-perfect copy. Mara lands there by mistake and logs in. It asks for her code. She types <code>482913</code>. The fake site forwards it to the <em>real</em> redline within the 30 seconds it's valid.</p>
<p>The thief is in.</p>
<p>The secret crossed the wire. It crossed Mara's eyes. Anything that travels can be intercepted or relayed.</p>
<p>A passkey never shares a secret at all.</p>
<h3>Collapse the definition: a passkey is a key pair</h3>
<p>A passkey is two keys that belong together.</p>
<p>A private key. And a public key.</p>
<p>That is all it is.</p>
<p>The private key stays inside the phone's secure hardware. Forever. It never leaves. The public key is the half you are allowed to hand out.</p>
<p>The whole trick is what each half can do:</p>
<pre><code class="language-plaintext">The private key can SIGN a message.
The public key can CHECK that signature — but can never produce one.
</code></pre>
<p>So Mara's phone proves it holds the private key by signing something. The server, holding only the public key, confirms the signature is genuine. But the server — or a thief who steals the server's entire database — can never sign anything <em>as</em> Mara. The public key doesn't let you. That's the asymmetry the whole feature stands on.</p>
<p>Nothing secret ever travels. Only the public key (safe to share) and signatures (useless to replay, as we'll see).</p>
<h3>Your face is not the second factor</h3>
<p>You might think the phone scans Mara's face and sends "yes, it's Mara's face" to the server.</p>
<p>It does not. Her face never leaves the phone.</p>
<p>Face ID is a <em>local lock</em>. It unlocks the private key sitting in the phone's secure chip. That is its only job.</p>
<pre><code class="language-plaintext">Face ID  →  unlocks the private key  →  the key signs
</code></pre>
<p>The server never sees a face. It never sees the private key. It sees a signature, and checks it against the public key it stored. Touch ID, a Windows Hello PIN, a YubiKey tap — same shape. A local gate that releases a local key.</p>
<h3>The challenge: why a signature can't be replayed</h3>
<p>If the phone just signed the word "redline" every time, a thief who captured one signature could resend it forever.</p>
<p>So the server never asks for a signature over something fixed. It sends a <strong>challenge</strong> — a fresh random number — and asks the phone to sign <em>that</em>.</p>
<pre><code class="language-plaintext">server: here is a random challenge → 9f2a7c...e1
phone:  sign(9f2a7c...e1) with the private key → &lt;signature&gt;
server: does &lt;signature&gt; check out against Mara's public key? yes.
</code></pre>
<p>Next login, a different random challenge. A captured signature is worthless — it answers a question the server will never ask again. A challenge is a one-time question; the signature is the one-time answer.</p>
<h3>RP ID vs origin: the part that actually kills phishing</h3>
<p>Two identifiers trip everyone up. They sound alike. They are not.</p>
<p><strong>RP ID</strong> — the domain the passkey belongs to. "Relying Party" is just jargon for "the site." Ours is <code>localhost</code> in dev, the real host in production.</p>
<p><strong>Origin</strong> — the exact URL the browser is actually at, like <code>http://localhost:3000</code>.</p>
<p>Here is the load-bearing rule, and it's enforced by the <em>browser itself</em>, not by our code:</p>
<pre><code class="language-plaintext">The browser binds every signature to the origin it is running on and refuses to use a redline passkey anywhere but redline's origin.
</code></pre>
<p>So go back to the fake <code>red1ine.com</code>. Mara's phone has a passkey for redline. The fake site asks for a signature. The browser checks: this passkey is bound to <code>localhost</code> / <code>redline.app</code>, and the page is <code>red1ine.com</code>. Mismatch. The browser <strong>will not even offer the key.</strong></p>
<p>There's no code on screen for Mara to mistype. There's no secret to forward. The one thing that proves her identity is locked to the real origin by the browser. That is why a passkey is phishing-resistant and a typed code is not — and it's the whole reason I reached for passkeys instead of TOTP, even though TOTP is friendlier to build.</p>
<h3>This is a SECOND factor, not passwordless</h3>
<p>A passkey <em>can</em> be your entire login — no password at all. That's "passwordless," and it's a different design. We are not doing that here.</p>
<p>Here the password (or Google sign-in) is factor one. The passkey is factor two. Both must pass. This choice isn't cosmetic — it changes the knobs we set when we build the options, and you'll see exactly where in a moment.</p>
<hr />
<h2>Second: how we store and verify a passkey</h2>
<p>The idea becomes rows and functions. Three concrete questions: where does the public key live, how do we verify a signature without writing crypto ourselves, and how does the server look the right key back up?</p>
<h3>The data model, and what's deliberately not in it</h3>
<p>One passkey is one row.</p>
<pre><code class="language-ts">// src/db/schema.ts
export const webauthnCredentials = pgTable('webauthn_credentials', {
  id: text('id').primaryKey(),              // the credential id from the authenticator (base64url)
  userId: uuid('user_id').notNull().references(() =&gt; users.id, { onDelete: 'cascade' }),
  publicKey: text('public_key').notNull(),  // base64url COSE public key — the only key we store
  counter: bigint('counter', { mode: 'number' }).notNull().default(0),
  transports: jsonb('transports').$type&lt;AuthenticatorTransportFuture[]&gt;(),
  deviceType: text('device_type'),          // 'singleDevice' | 'multiDevice'
  backedUp: boolean('backed_up'),
  name: text('name'),                       // "iPhone 15" — a user label
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
})
</code></pre>
<p>Notice what is <em>not</em> there. There's no private-key column. There's no "secret." The server never had Mara's private key and never will. The only key material here is <code>public_key</code> — the half that can check a signature but can't make one.</p>
<p>And notice the primary key. <code>id</code> is not a fresh uuid we generate; it's the <strong>credential id the authenticator handed back</strong> at enrollment. That string is how the browser refers to the key on later logins, so it <em>is</em> the natural key — we look the row up by exactly the value an assertion reports.</p>
<p>There's also no <code>twoFactorEnabled</code> boolean, and that's deliberate. A boolean would be a second copy of the truth, free to drift out of sync with reality. Instead, 2FA is <em>on</em> when Mara has at least one credential row:</p>
<pre><code class="language-typescript">// the login gate — a COUNT, not a flag
export const hasEnrolledPasskey = async (userId: string): Promise&lt;boolean&gt; =&gt; {
  const [row] = await db
    .select({ total: count() })
    .from(webauthnCredentials)
    .where(eq(webauthnCredentials.userId, userId))
  return (row?.total ?? 0) &gt; 0
}
</code></pre>
<p>Delete her last credential and she's back to single-factor — automatically, with no flag to remember to flip. (This pays off later: "disable 2FA" becomes "delete the rows," nothing more.)</p>
<h3>Do NOT write the crypto</h3>
<p>Here is the part it's tempting to build yourself.</p>
<p>Verifying a registration means taking the blob the browser returned and pulling the public key out of it. That blob is a <strong>CBOR-encoded attestation object</strong>, wrapping a <strong>COSE-encoded public key</strong>, wrapping signature flags and counters. Verifying a login means decoding authenticator data, re-hashing client data, and checking an ECDSA or RSA signature with exactly the right parameters.</p>
<p>This is the bad version:</p>
<pre><code class="language-typescript">// do not do this
const attestation = decodeCbor(response.attestationObject)
const publicKey = parseCoseKey(attestation.authData.slice(/* ...offsets... */))
// ...and now you are one off-by-one away from a CVE
</code></pre>
<p>Get one offset or one flag wrong and you either reject every honest user, or — worse — accept a forged one.</p>
<p>So we don't. We use <a href="https://www.npmjs.com/package/@simplewebauthn/server"><code>@simplewebauthn/server</code></a> for the parsing and signature math. We orchestrate; the library does the dangerous bytes. That's the entire division of labor in <code>src/auth/webauthn.ts</code> — the same reasoning that made <code>argon2id</code> and <code>arctic</code> library choices in the first post. Crypto is the thing you stand on a vetted primitive for, never hand-roll. (Its return shape changed across major versions, so the field names in our code were read off the installed <code>.d.ts</code>, not from memory.)</p>
<h3>The two option flags that <em>are</em> the design</h3>
<p>Before the phone can do anything, the server hands the browser an "options" blob — the parameters for <code>navigator.credentials.create()</code> (enroll) or <code>.get()</code> (login). This is where second-factor-not-passwordless stops being a sentence and becomes two flags:</p>
<pre><code class="language-typescript">// src/auth/webauthn.ts — enrollment options
generateRegistrationOptions({
  rpID: env.RP_ID,
  rpName: env.RP_NAME,
  userID: isoUint8Array.fromUTF8String(input.userId),
  userName: input.userName,
  attestationType: 'none',
  excludeCredentials: input.existingCredentials.map(toCredentialDescriptor),
  authenticatorSelection: { residentKey: 'discouraged', userVerification: 'preferred' },
})
</code></pre>
<p>Read the last line, because it <em>is</em> the policy.</p>
<p><code>residentKey: 'discouraged'</code> — do not ask the phone to store a <em>discoverable</em> credential. A discoverable credential is the passwordless feature: it lets you log in with no username at all. We don't want that. The password is factor one; the passkey only confirms it.</p>
<p><code>userVerification: 'preferred'</code> — ask for the biometric (the Face ID Mara wanted) where the device can do it, but don't <em>hard-fail</em> an authenticator that can't. A bare security key with no fingerprint reader still proves possession, and possession is the second factor.</p>
<p><code>excludeCredentials</code> is the list of keys Mara already enrolled — the browser greys those out so she can't register the same iPhone twice. The login side is the mirror image: instead of excluding, it passes <code>allowCredentials</code> (her credential ids), because the password already told us <em>who she is</em>, so we can name exactly which keys may answer.</p>
<p>Both builders generate a fresh random challenge inside the returned options. The caller's job is to stash that challenge somewhere short-lived so the verify step can demand the same one back — which is the next two sections.</p>
<h3>The public key: base64url, decoded only at the edge</h3>
<p>The library hands us a public key as raw bytes — a <code>Uint8Array</code>. Postgres columns are text. So we encode to base64url on the way in:</p>
<pre><code class="language-typescript">// after a registration verifies
publicKey: isoBase64URL.fromBuffer(credential.publicKey),  // bytes → "pQECAyYgASFY..."
</code></pre>
<p>When Mara logs in later, the library needs the bytes back to check her signature. So we decode at exactly one place — the verify boundary — and nowhere else:</p>
<pre><code class="language-typescript">// inside verifyPasskeyAuthentication
credential: {
  id: input.credential.credentialId,
  publicKey: isoBase64URL.toBuffer(input.credential.publicKey),  // "pQECAyYgASFY..." → bytes
  counter: input.credential.counter,
  transports: input.credential.transports ?? undefined,
}
</code></pre>
<p>Text at rest. Bytes for one function call. The conversion never leaks into the rest of the code.</p>
<p>And the phishing defense from the first section becomes two arguments on that same verify call:</p>
<pre><code class="language-typescript">const EXPECTED_ORIGIN = new URL(env.APP_URL).origin  // strips a stray trailing slash

await verify({
  response: input.response,
  expectedChallenge: input.expectedChallenge,
  expectedOrigin: EXPECTED_ORIGIN,   // the URL the browser must have been at
  expectedRPID: env.RP_ID,           // the domain the key must be bound to
  requireUserVerification: false,    // honors 'preferred' — don't reject UV-less authenticators
})
</code></pre>
<p><code>new URL(env.APP_URL).origin</code> is not fussiness. WebAuthn compares the origin byte-for-byte. A trailing slash in <code>APP_URL</code> would silently fail <em>every</em> verification, and you'd spend an afternoon wondering why valid passkeys are rejected. Deriving <code>.origin</code> strips it.</p>
<h3>Why we never judge the counter ourselves</h3>
<p>Every authenticator keeps a <strong>signature counter</strong> — how many times it has signed. The idea is clone detection: if a counter ever goes <em>backwards</em>, two copies of the key might exist.</p>
<p>So the naive rule is: store the counter, reject any login where the new counter isn't strictly greater.</p>
<p>That rule locks Mara out.</p>
<p>Modern synced passkeys — iCloud Keychain, Google Password Manager — report a counter of <code>0</code>. Forever. They live in the cloud, not one chip, so the count is meaningless and they just send zero. Enforce "must increase" and every iPhone passkey fails on its second use.</p>
<p>So we don't enforce it. We hand the stored counter to the library, let <em>it</em> apply the spec-correct check, and simply persist whatever new value comes back. The counter is the library's to interpret; we're just its storage.</p>
<p>This module is deliberately dumb. It builds options, verifies responses, reads and writes rows. It does <em>not</em> know about challenges-in-Redis, login state, or who's allowed to call it. That's on purpose — it stays a pure "given a challenge, prove the key" layer, so the stateful, security-critical orchestration lives in one place you can audit. That place is the next three sections. If you ever find auth-flow logic creeping into this file, that's the smell; push it back out.</p>
<hr />
<h2>Third: the gap between "password OK" and "you're in"</h2>
<p>Mara has a passkey now. So her login has two steps, not one.</p>
<p>Step one: email and password. Correct. Step two: her phone signs a challenge. Between those two steps, something has to remember her.</p>
<p>Without 2FA, login was one moment:</p>
<pre><code class="language-plaintext">password correct → mint a session → set the cookie → done
</code></pre>
<p>With 2FA, there's a gap in the middle:</p>
<pre><code class="language-plaintext">password correct → ??? → second factor → mint a session → done
</code></pre>
<p>What lives in the <code>???</code>. Mara has proven one factor and not the second. She is <strong>half-authenticated</strong> — more than a stranger, less than logged in. You can't give her a session yet; a session <em>is</em> "fully logged in." But you have to remember <em>something</em> across her next few requests, or step two has no idea who's knocking.</p>
<h3>The naive version, and why it quietly defeats 2FA</h3>
<p>Here's the tempting shortcut. The <code>/login</code> handler just checked Mara's password, so it knows her userId. Hand that to the browser and let the second-factor requests send it back:</p>
<pre><code class="language-plaintext">POST /login            
{ mfaRequired: true, userId: "mara-uuid" }   ← BAD
POST /2fa/verify         
{ userId: "mara-uuid", assertion: ... }       ← BAD
</code></pre>
<p>Read that second line again. The userId is coming <em>from the client</em>.</p>
<p>Now picture an attacker. They have their own account and their own passkey. They know Mara's userId (it leaked, or it's in a URL somewhere). They call the verify endpoint with <strong>Mara's userId</strong> and <strong>their own</strong> assertion — signed by <strong>their own</strong> phone, which they can Face-ID all day. If the server trusts that userId and just checks "is this a valid assertion for <em>some</em> registered credential," the attacker walks into Mara's account.</p>
<p>The password — the first factor — just became decorative. The second factor authenticated the <em>attacker's</em> finger against the <em>attacker's</em> key, but logged them into <em>Mara's</em> account. The bug is one word: the userId came from the request.</p>
<h3>The fix: the second factor's identity comes only from server state</h3>
<p>The rule that makes 2FA real, and the second of our three themes:</p>
<pre><code class="language-plaintext">After the password passes, the userId for the second factor
comes ONLY from server-side state — never from the request.
</code></pre>
<p>So at <code>/login</code>, when the password is right and Mara has a passkey, we don't hand her a userId. We mint a <strong>pending-MFA token</strong> and keep the userId on <em>our</em> side, in Redis, under the token's hash:</p>
<pre><code class="language-typescript">// src/auth/mfa.ts
export const createPendingMfa = async (input: { userId: string }) =&gt; {
  const rawToken = generateToken()                          // 256-bit random
  const value = { userId: input.userId, challenge: null }
  await redis.set(pendingKey(rawToken), JSON.stringify(value), 'EX', PENDING_MFA_TTL_SECONDS)
  return { rawToken, expiresAt: new Date(Date.now() + PENDING_MFA_TTL_MS) }
}
</code></pre>
<p>The raw token goes to Mara in a cookie. The userId stays in Redis. When step two arrives, the server reads the userId back <em>from Redis</em>, keyed by the token in her cookie. The attacker can send any userId they like in the body — nobody reads it. The only userId that matters is the one <em>we</em> wrote, that <em>only Mara's cookie</em> can point at. The first factor is load-bearing again.</p>
<h3>Why it's a different cookie from the session</h3>
<p>Mara's pending token rides a cookie called <code>redline_mfa</code>. Not the session cookie. On purpose.</p>
<p>Here's the trap: if the half-auth token were accepted by <code>getSessionUser</code>, then "password correct, second factor still pending" would already <em>be</em> "logged in" — and the second factor would be skipped entirely. So the pending token lives in its own cookie, resolved only by <code>loadPendingMfa</code>, and the session resolver never looks at it.</p>
<pre><code class="language-typescript">// src/auth/cookies.ts
export const MFA_COOKIE_NAME = 'redline_mfa'   // separate from SESSION_COOKIE_NAME
</code></pre>
<p>Two cookies. One means "logged in." The other means "halfway there." They never cross.</p>
<h3>Same hash-at-rest trick, single-use, bounded</h3>
<p>The pending token is a key — whoever holds it can finish Mara's login — so we treat it exactly like the session token from the first post. The cookie holds the raw token; Redis stores only its <code>sha256</code> hash as the key:</p>
<pre><code class="language-typescript">const pendingKey = (rawToken: string) =&gt; `mfa:pending:${hashToken(rawToken)}`
</code></pre>
<p>A Redis leak hands the attacker a list of hashes that key nothing without the raw token, which only ever lived in Mara's cookie. (Why sha256 and not argon2? Same split as the first post: the token is 256 bits of randomness, not a guessable password — nothing to brute-force, so the fast hash is correct.)</p>
<p>Two more properties keep the gap small. It's <strong>single-use</strong> — deleted the instant the second factor succeeds, so a finished login can't be replayed (we consume only on success, so a failed Face ID leaves the token alive for a retry). And it's <strong>bounded</strong> — a 10-minute TTL, so the half-authenticated state can't linger. Plenty of time to glance at a prompt and tap; after that it evaporates and Mara starts over. The challenge gets written into this same entry later, and even <em>that</em> write re-reads the userId from Redis rather than trusting the caller — the invariant holds at every step.</p>
<p>This whole flow exists <em>because</em> we chose 2FA. A passwordless design has no "half" state to hold — the assertion either logs you in or it doesn't. The gap is the price of a first factor, and the pending token is how you guard it.</p>
<hr />
<h2>Fourth: recovery codes, for when the phone goes in the lake</h2>
<p>Mara enrolled a passkey. Her account is safer. It's also more fragile in exactly one way: the passkey lives in one device's secure hardware. Drop that phone in a lake, and the private key is at the bottom of the lake with it.</p>
<h3>Why "reset my passkey" cannot exist</h3>
<p>With a password, losing it is fine. You click "forgot password," prove you own the email, set a new one — the server always <em>could</em> set a new password, because the server controls it. A passkey is the opposite. The server never had the private key. It can't reset what it never held. There is no "email me a new passkey."</p>
<p>So the naive recoveries don't work:</p>
<pre><code class="language-plaintext">"Let support flip 2FA off."           → now a support social-engineer is your second factor.
"Email a magic link to disable 2FA."  → now your email is your second factor, and email is phishable.
</code></pre>
<p>Each quietly hands the second factor back to something weaker. The whole point was to <em>not</em> depend on a phishable channel. We need a backup as strong as the passkey, handed to Mara up front, while she still has the device. That's a recovery code.</p>
<p>A recovery code is a one-time password you were given in advance. We generate ten recovery codes the moment Mara enrols her first passkey, show them once, and she saves them somewhere safe. Months later, phone in the lake, she types one in instead of using her passkey. It logs her in. Then it's dead — used up, never again.</p>
<h3>What a code looks like, and why</h3>
<pre><code class="language-plaintext">A7KM-9QR3-FXP2
</code></pre>
<p>Three groups of four, from a deliberately small alphabet:</p>
<pre><code class="language-typescript">const RECOVERY_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // no 0 O 1 I L
</code></pre>
<p>Look at what's missing: no <code>0</code> or <code>O</code>, no <code>1</code> or <code>I</code> or <code>L</code>. Those are the characters people mistype when reading off paper. Leave them out and a misread can't accidentally land on a <em>different</em> valid code. Each code is 12 characters from 32 symbols — about 60 bits of entropy, unguessable, and single-use and rate-limited on top, so there's nothing to brute-force.</p>
<p>The dashes are just for the eye. When Mara types it back, we don't care about her dashes or her caps lock:</p>
<pre><code class="language-typescript">const normalizeRecoveryCode = (rawCode: string): string =&gt;
  rawCode.toUpperCase().replace(/[\s-]/g, '')
</code></pre>
<p><code>a7km9qr3fxp2</code>, <code>A7KM-9QR3-FXP2</code>, <code>A7KM 9QR3 FXP2</code> all canonicalize to the same thing before we hash it. The stored hash and the typed-back code always meet in one shape.</p>
<h3>Stored hashed, salted with the userId</h3>
<p>We never store the codes themselves — a database leak would otherwise hand the attacker ten working 2FA-bypasses per user. So we store only a hash, salted with the userId:</p>
<pre><code class="language-typescript">const recoveryCodeId = (userId: string, normalizedCode: string): string =&gt;
  hashToken(`\({userId}:\){normalizedCode}`)   // sha256(userId + ':' + code)
</code></pre>
<p>Why salt with the userId? A bare <code>sha256(code)</code> would be global — two users who happened to get the same code would collide on the primary key, and identical codes would produce identical hashes, leaking that they match. Salting per-user scopes each hash to its owner. And a code is only ever looked up in the context of a <em>known</em> user — at login, the one from the pending-MFA token, never the request. Same invariant, carried through.</p>
<h3>Single-use, enforced by the database</h3>
<p>"Each code works exactly once" sounds like a rule you check in code. It isn't. It's enforced in one query:</p>
<pre><code class="language-typescript">export const consumeRecoveryCode = async (userId: string, rawCode: string): Promise&lt;boolean&gt; =&gt; {
  const id = recoveryCodeId(userId, normalizeRecoveryCode(rawCode))
  const consumed = await db
    .update(recoveryCodes)
    .set({ usedAt: new Date() })
    .where(and(eq(recoveryCodes.id, id), eq(recoveryCodes.userId, userId), isNull(recoveryCodes.usedAt)))
    .returning({ id: recoveryCodes.id })
  return consumed.length &gt; 0
}
</code></pre>
<p>The <code>isNull(usedAt)</code> in the WHERE is the whole trick. It says: mark this code used <em>only if it is currently unused</em>. The database does the check and the write in one atomic step. So picture a double-submit — Mara fat-fingers the button twice, two requests race. Both compute the same code id, both try to update <code>WHERE used_at IS NULL</code>. The database lets exactly one match: the first sets <code>used_at</code>, the second now finds nothing matching and updates zero rows. <code>.returning()</code> tells us which we were — one row means we consumed it, zero means unknown-or-already-spent. No read-then-write gap for a race to slip through. (It's the same shape as the <code>23505</code> unique-constraint trick from the first post: let the database be the atomic judge instead of checking-then-acting.)</p>
<p>The codes are shown exactly once. <code>generateRecoveryCodes</code> returns the raw codes for the route to display and keeps only the hashes — there is no "show me my codes again" endpoint, because there's nothing to show; we threw the originals away on purpose. Regenerating is the same call: it deletes the old batch in a transaction and writes a fresh ten, so an old printout stops working the moment a new one is issued.</p>
<p>The honest cost: a recovery code is a bearer secret. Whoever holds it passes the second factor — that's the entire job — so a leaked code is as dangerous as a leaked password, minus the username. That's the deal we accept for not locking Mara out forever, and we blunt it the obvious ways (single-use, rate-limited, regeneration burns the old set, and we tell her to store them like passwords). If you could guarantee every user a <em>second</em> hardware key in a safe, you'd sidestep the printable bypass entirely — but most people don't have a spare YubiKey in a drawer. Mara doesn't. So she gets codes.</p>
<hr />
<h2>Fifth: wiring the two-factor login</h2>
<p>We have all the parts — the service that verifies a passkey, the pending-login token, recovery codes. This is the assembly: the actual endpoints Mara's browser calls. Two journeys, turning 2FA on and then logging in with it.</p>
<h3>Login now has two endings</h3>
<p>Before 2FA, <code>loginWithPassword</code> had one outcome: a session. Now it has two, and the type says so out loud:</p>
<pre><code class="language-typescript">// src/auth/password-auth.ts — the two outcomes named once, so no bare 'mfa_required' string drifts around
export const PASSWORD_LOGIN_STATUS = {
  authenticated: 'authenticated',
  mfaRequired: 'mfa_required',
} as const

export type PasswordLoginResult =
  | { status: typeof PASSWORD_LOGIN_STATUS.authenticated; user: User; session: CreatedSession }
  | { status: typeof PASSWORD_LOGIN_STATUS.mfaRequired; userId: string }
</code></pre>
<p>A correct password is no longer the end of the story. If the user has a passkey, the function stops and returns <code>mfaRequired</code> with <em>only</em> the userId — no session:</p>
<pre><code class="language-typescript">if (await hasEnrolledPasskey(account.userId)) {
  return { status: PASSWORD_LOGIN_STATUS.mfaRequired, userId: account.userId }
}
</code></pre>
<p>One detail matters for security: this branch is reached <em>only after the password verified</em>. So <code>mfaRequired</code> can never tell an attacker "this email has 2FA" — they'd have needed the right password to see it. (The status values are a named <code>const</code> object rather than bare strings on purpose — one source of truth, so a typo at any call site is a compile error instead of a silent miss.)</p>
<p>The <code>/login</code> route reads the discriminated result and forks:</p>
<pre><code class="language-typescript">// src/auth/routes.ts
if (result.status === PASSWORD_LOGIN_STATUS.mfaRequired) {
  const pending = await createPendingMfa({ userId: result.userId })
  setMfaCookie(reply, pending.rawToken, pending.expiresAt)
  return reply.send({ mfaRequired: true })   // ← no session cookie on this path
}
setSessionCookie(reply, result.session.rawToken, result.session.expiresAt)
return reply.send({ user: publicUser(result.user, linkedProviders) })
</code></pre>
<p>The fork is the whole point: one branch hands out a session, the other hands out a <em>pending</em> token and asks for more.</p>
<h3>Turning 2FA on: the enrollment round trip</h3>
<p>Mara is already signed in. She clicks "add a passkey." Two requests:</p>
<pre><code class="language-plaintext">POST /auth/2fa/register/options   (her session cookie proves who she is)
  → build options { challenge, excludeCredentials, rp, ... }
  → store the challenge in Redis, keyed by her userId
  → return options
browser: navigator.credentials.create(options)   ← Face ID; makes the key pair
POST /auth/2fa/register/verify    { response, name? }
  → take the stored challenge back (GETDEL — single use)
  → verifyPasskeyRegistration(response, challenge)
  → store the public key
  → if this is her FIRST passkey: generate 10 recovery codes, return them ONCE
</code></pre>
<p>Notice where the enrollment challenge lives. Mara has a session here, so we key it by her userId, and read-and-delete it atomically so it can only be spent once:</p>
<pre><code class="language-typescript">// src/auth/webauthn-challenge.ts
const registrationChallengeKey = (userId: string) =&gt; `webauthn:challenge:reg:${userId}`

export const takeRegistrationChallenge = (userId: string) =&gt;
  redis.getdel(registrationChallengeKey(userId))   // read-and-delete, atomic, single-use
</code></pre>
<p>That's different from the <em>login</em> challenge, which has no session to key on and rides the pending-MFA entry instead. Same idea — a server-issued challenge the verify step demands back — keyed differently because enrolment knows the user and login doesn't yet.</p>
<p>The recovery codes are minted <em>here</em>, at the first passkey:</p>
<pre><code class="language-ts">const isFirstPasskey = !(await hasEnrolledPasskey(active.userId))

await storePasskey({ userId: active.userId, registration: verified, name: body.name ?? null })

const recoveryCodes = isFirstPasskey ? await generateRecoveryCodes(active.userId) : undefined

return { credentialId: verified.credentialId, recoveryCodes }
</code></pre>
<p>The moment 2FA turns on is the moment Mara could get locked out — so that's the moment she gets her backup. Check <code>isFirstPasskey</code> <em>before</em> storing, or the new row makes every enrollment look like the first.</p>
<h3>Logging in with 2FA: the second round trip</h3>
<p>Now the everyday path:</p>
<pre><code class="language-plaintext">POST /auth/login           → password OK, has passkey → { mfaRequired: true }
                             + redline_mfa cookie, NO session
POST /auth/2fa/authenticate/options   (reads redline_mfa cookie)
  → load the pending login → her userId
  → build options { challenge, allowCredentials: her keys }
  → attach the challenge to the pending entry
  → return options
browser: navigator.credentials.get(options)   ← Face ID; signs the challenge
POST /auth/2fa/authenticate/verify    { response }
  → load pending → { userId, challenge }
  → the asserted credential must belong to THIS user        ← the guard
  → verifyPasskeyAuthentication(response, challenge, storedKey)
  → update the counter
  → finishMfaLogin: burn pending, clear cookie, MINT SESSION
</code></pre>
<p>Every one of those <code>/2fa/authenticate</code> handlers begins the same way — by resolving the cookie to a pending login, server-side, with the userId coming out of Redis and never out of the request body. That's the invariant from the third section, now enforced at every route that finishes a login.</p>
<h3>The guard that makes the second factor real</h3>
<p>Here's the line the whole feature hinges on:</p>
<pre><code class="language-typescript">const pending = await requirePendingMfa(req.cookies[MFA_COOKIE_NAME])
// ...
const credential = await getPasskey(body.response.id)
const isOwnedByPendingUser = credential !== null &amp;&amp; credential.userId === pending.userId
      
if (!isOwnedByPendingUser) {
   throw badRequest('webauthn_unknown_credential', 'That passkey is not registered here.')
}
</code></pre>
<p>Picture the attack one more time. An attacker has <em>their own</em> account and <em>their own</em> passkey. They get hold of the victim's pending cookie but sign the challenge with their <em>own</em> Face ID, their <em>own</em> key. Without this check, the server might think "this is a valid assertion for a real, registered passkey" and let them in — as the victim.</p>
<p>The check stops it cold. The asserted credential's <code>userId</code> must equal the pending login's <code>userId</code>. The attacker's key belongs to the attacker, not the victim. Mismatch. Rejected <em>before</em> we even verify the signature. A valid assertion for the wrong account is worth nothing.</p>
<h3>Session creation has exactly one home</h3>
<p>In the whole 2FA flow, <code>createSession</code> runs in one place — the shared tail both success paths call:</p>
<pre><code class="language-typescript">const finishMfaLogin = async (reply, req, rawMfaToken, userId) =&gt; {
  await consumePendingMfa(rawMfaToken)   // burn the pending token (single-use)
  clearMfaCookie(reply)                  // drop the half-auth cookie
  const session = await createSession({ userId, ip: req.ip, userAgent: ... })
  setSessionCookie(reply, session.rawToken, session.expiresAt)
  // ...return the public user
}
</code></pre>
<p>Passkey login calls it after a verified assertion. Recovery-code login calls it after a consumed code (same shape, minus the signature: <code>consumeRecoveryCode</code> instead of <code>verifyPasskeyAuthentication</code>, then the very same tail). Nowhere else in the flow is a session minted. One door to "you're fully in," and both factors have to walk through it.</p>
<p>Five routes for "log in" is a lot of surface, and it's tempting to read it as over-engineering. But WebAuthn <em>is</em> a challenge/response handshake: the server issues a challenge, the device signs it, the server verifies that exact challenge. That's inherently two round trips per phase — options, then verify — and you can't collapse them without throwing away the replay protection the challenge buys. The endpoint count isn't accidental complexity; it's the protocol's shape made honest. (Putting them in their own <code>twofa-routes.ts</code> plugin instead of piling onto <code>routes.ts</code> is just housekeeping, so the core auth file stays about passwords and sessions.)</p>
<hr />
<h2>Sixth: managing passkeys, and the step-up that guards the off switch</h2>
<p>Mara turned on 2FA. Now she lives with it. She buys a laptop and enrolls a second key. She names her phone "iPhone." She sells the laptop and removes its key. One day she wants 2FA off entirely. Every one of those is a management action. Most are ordinary. One is dangerous. This section is about telling them apart.</p>
<h3>The ordinary actions</h3>
<p>Listing, renaming, removing-one. Mara is signed in; her session proves who she is; the server edits her own rows:</p>
<pre><code class="language-plaintext">GET    /auth/2fa/credentials       → her passkeys + how many recovery codes are left
PATCH  /auth/2fa/credentials/:id   → rename one
DELETE /auth/2fa/credentials/:id   → remove one (but see below)
</code></pre>
<p>Two small things keep these safe. The list never leaks key material — the Security page sees a projection (id, name, backedUp, timestamps), never the public key, counter, or userId, the same discipline as <code>publicUser</code> for accounts. And edits are scoped to the owner: rename and delete both filter by <code>userId</code>, so a credential id alone can't touch someone else's key. If the row doesn't belong to Mara, nothing matches, and she gets a clean "no such passkey" — never a peek at whether that id exists for someone else.</p>
<h3>The one dangerous action: dropping to zero</h3>
<p>Here's the asymmetry that matters. Removing <em>a</em> passkey when Mara has two is fine — she still has one, 2FA is still on. Removing her <em>last</em> passkey is different: that turns 2FA off. So does the explicit "disable 2FA." Those two — remove-the-last and disable — are the dangerous ones, because they take the account from protected to unprotected.</p>
<p>So <code>DELETE</code> simply refuses to be the off switch:</p>
<pre><code class="language-typescript">if ((await countPasskeys(active.userId)) &lt;= 1) {
  throw conflict('last_passkey', 'This is your last passkey. Disable 2FA to remove it.')
}
</code></pre>
<p>You cannot quietly delete your way to zero. The last step has to go through <code>/disable</code>, where there's a stronger gate.</p>
<h3>Why a valid session is not enough</h3>
<p>Picture the attack this gate exists for. Someone steals Mara's live session — a cookie lifted off an unlocked laptop, a hijacked tab. As far as the server can tell, they're signed in as Mara. If "disable 2FA" only needed a session, they'd just turn it off, then change her password, and the second factor that was supposed to protect her is gone — removed by the very session it was meant to backstop.</p>
<p>So disabling 2FA demands something the session-thief doesn't have: a <strong>fresh factor</strong>, proven <em>right now</em>.</p>
<pre><code class="language-ts">const proven = await proveFreshFactor({ userId, sessionId, proof })
if (!proven) {
  throw forbidden('step_up_failed', 'Confirm a passkey or a recovery code to disable 2FA.')
}
await disableTwoFactor(active.userId)
</code></pre>
<p>This is <strong>step-up</strong>: a valid session gets you to the door, but a sensitive action makes you prove a factor again before it opens. A fresh factor is one of two things, because Mara might be in either situation. A passkey assertion is the clean path — she taps Face ID, signs a fresh challenge, nothing spent; the challenge for it is issued by <code>/stepup/options</code> and keyed to <em>her session</em>, so a challenge minted for one session can't be redeemed by another. A recovery code is the fallback, for when she's disabling 2FA <em>because</em> she lost the device. The session-thief has neither — they hold a cookie, not the phone and not the printed codes. And the assertion path reuses the <em>exact</em> ownership check from the login flow: a fresh factor has to be <em>Mara's</em> fresh factor.</p>
<p>When step-up passes, <code>disableTwoFactor</code> wipes 2FA in one transaction — both <code>webauthnCredentials</code> and <code>recoveryCodes</code> for that user, together, so there's never a half state where the keys are gone but stale codes linger. And because "2FA is enabled" is <em>derived</em> from the credential count, deleting the keys is all it takes to flip the login gate back to single-factor. There's no separate flag to forget. (That design choice from the second section paying off, exactly as promised.)</p>
<p>Step-up is a deliberate speed bump, and the art is putting it only where it earns its friction. Renaming a passkey doesn't get it — relabeling "iPhone" to "Work phone" changes no security posture, so demanding Face ID would be theater. Removing a non-last key doesn't either: 2FA stays on, the blast radius is small. We spend the friction only where the account goes from protected to unprotected. Guard the off switch; leave the light switches alone.</p>
<hr />
<h2>Seventh: the browser's half of the handshake</h2>
<p>Everything so far has been the server. Now the part Mara actually touches. She types her password, a passkey prompt appears, she looks at her phone, she's in. Three button-presses of UI on top of all that backend — and one genuinely tricky moment.</p>
<h3>The fork that sends her to /2fa</h3>
<p>The API can now answer login two ways, and the type says so:</p>
<pre><code class="language-typescript">// web/src/lib/api.ts
export type LoginResult = { user: PublicUser } | { mfaRequired: true }
</code></pre>
<p>So the login page reads which branch it got and routes accordingly:</p>
<pre><code class="language-typescript">// web/src/pages/LoginPage.tsx
const result = await API_login({ email, password })
if ('mfaRequired' in result) {
  navigate('/2fa')   // password was right; the second factor is next
  return
}
await refresh()
navigate('/')         // no 2FA — straight in
</code></pre>
<p>Notice what the client does <em>not</em> receive on the <code>mfaRequired</code> branch: no token, no userId, nothing. The pending-MFA cookie was set by the server, httpOnly, invisible to JavaScript. The browser just knows "go to /2fa," and the cookie rides along automatically on the next request. (The invariant again, seen from the client's side: the browser literally cannot name <em>whose</em> login this is, because it was never told.)</p>
<h3>The three-step flow</h3>
<p>On <code>/2fa</code>, Mara taps "Verify with passkey." That kicks off a handshake the page orchestrates in three moves:</p>
<pre><code class="language-typescript">// web/src/pages/TwoFactorPage.tsx
const options = await API_2faAuthenticateOptions()                  // 1. ask the server (network)
const assertion = await startAuthentication({ optionsJSON: options }) // 2. hand to the device (Face ID)
await API_2faAuthenticateVerify(assertion)                          // 3. send the signed result (network)
await finishLogin()
</code></pre>
<p>Step 2 is the only line that isn't a network call. <code>startAuthentication</code> is from <code>@simplewebauthn/browser</code> — the wrapper around <code>navigator.credentials.get()</code>, the thing that actually makes the OS show the Face ID sheet, unlock the private key, and sign the server's challenge. That's why the device step lives in the <em>page</em>, not in <code>api.ts</code>: the <code>API_</code> functions are network calls and nothing else (that prefix is a promise), while the browser ceremony is a different kind of operation, so it sits where the user gesture is.</p>
<p>And it <em>is</em> a gesture — a button, not an auto-run. The page could fire <code>startAuthentication</code> on mount; it doesn't. Browsers gate the WebAuthn prompt behind a real click, partly so a page can't silently pop a credential request the instant you land on it. So <code>/2fa</code> shows a button, and the handshake starts when Mara presses it. The gesture is both a browser requirement and the honest UX: she <em>chose</em> to authenticate.</p>
<h3>When the phone is in the lake</h3>
<p>Mara might not have her passkey — lost phone, wiped laptop. So the page has a second door: "Use a recovery code instead" swaps the passkey button for a text field, and one of the codes goes to the same finish line, minus the device. Both paths end in <code>finishLogin</code>, which does the one thing the client <em>can</em> do once the cookie is set: re-ask the server who it is.</p>
<pre><code class="language-typescript">const finishLogin = async () =&gt; {
  await refresh()   // GET /auth/me — the server is the source of truth
  navigate('/')
}
</code></pre>
<p>The session cookie is httpOnly — the client can't read it to know "am I logged in now?" So it asks. <code>refresh()</code> pulls <code>/auth/me</code>, the auth context flips to authenticated, and Mara lands on the dashboard.</p>
<p>A passkey login can fail two very different ways, and the page flattens them into one human sentence. An <code>ApiError</code> means the <em>server</em> said no (expired pending login, rejected assertion) — show its message. A <code>WebAuthnError</code> means the <em>browser ceremony</em> broke — Mara hit cancel, the sensor timed out, there's no matching credential on this device. That never reached the server, so there's no server message; the page supplies its own and points her at the recovery-code escape hatch.</p>
<p>The temptation here is to make the frontend smarter — cache the user, decode something, track "2FA pending" in React state. Resist it. The client holds no secret and no authority: it can't read the httpOnly cookies, it can't verify a passkey, it can't decide who's logged in. Its whole job is to route to the right screen, trigger the device prompt on a click, and ask the server what's true afterward. Every time the frontend is tempted to <em>know</em> something about auth, the right move is to ask <code>/auth/me</code> instead. A dumb client is the secure client.</p>
<h3>The one place that rule breaks: the show-once problem</h3>
<p>Enrolling is the mirror of logging in — same three-step shape, different verb in the middle. <code>startRegistration</code> (the create-time twin of <code>startAuthentication</code>) wraps <code>navigator.credentials.create()</code>: the OS prompts for Face ID, the device generates a fresh key pair in its secure hardware, and hands back the <em>public</em> key. The server stores it. Mara has a passkey.</p>
<p>But when she enrolls her <em>first</em> one, the server turns 2FA on and mints ten recovery codes — and returns them in that one verify response, never again:</p>
<pre><code class="language-ts">const result = await API_2faRegisterVerify({ response, name })
if (result.recoveryCodes !== undefined) {
  setNewCodes(result.recoveryCodes)   // present them NOW; there is no second chance
}
</code></pre>
<p>Why only once? Because the server stores codes hashed — it cannot show them again because it threw the originals away on purpose. There is no "resend my codes" endpoint, because there is nothing to resend.</p>
<p>So the UI has a duty the rest of the page doesn't: it must make Mara <em>stop and save them</em>. The codes live in component state, shown in a loud amber box, dismissed only by an explicit "I've saved them." Reload the page and they're gone — exactly as they should be.</p>
<p>This is the one screen where the "dumb client" rule inverts. Almost everywhere in this system, losing a client-side value is harmless — the server is the truth, just ask again. The recovery codes are the single exception: for one render, the client is the <em>only</em> holder of the plaintext, and if the page fails to make Mara save them, nothing else can recover them. So here UX <em>is</em> security. The loud box, the explicit acknowledge, the refusal to tuck the codes behind a reload — those aren't polish, they're the safeguard. Get the show-once moment wrong and you've built a 2FA that quietly locks people out the first time they lose a phone. Everywhere else the client can be dumb; here it has to be insistent.</p>
<p>(Disabling, from the client side, is just the login handshake pointed at a destructive action: <code>stepup/options → startAuthentication → disable</code>, or a recovery code instead. The server enforces that a session alone isn't enough; the page only collects the fresh factor and passes it along. Add, remove, rename, disable all run through one busy/error wrapper, so the page has many actions but one way to be busy and one way to fail.)</p>
<hr />
<h2>The gap I didn't close: Google sign-in doesn't enforce 2FA</h2>
<p>Honesty, the same as the first post earned. There's a hole here I left open on purpose, and it's worth naming plainly rather than hoping you don't notice.</p>
<p><strong>A user with a passkey can still log in through "Continue with Google" without the second factor.</strong></p>
<p>The reason is structural. The password path runs <em>through our server</em> — <code>loginWithPassword</code> returns <code>mfaRequired</code>, and the route withholds the session. The Google path is a redirect flow that mints a session directly in the callback (that's the whole OAuth flow from the first post). Gating it means redirecting <em>out</em> of the callback to a frontend <code>/2fa</code> step, carrying the pending cookie, before the session is set — real extra work that I scoped out of this slice.</p>
<p>So the threat model this 2FA actually defends is the password one: a leaked or reused password no longer earns an account on its own. A determined attacker who can complete Mara's Google OAuth is outside what this slice stops. That's a real limitation, not an oversight — and it's exactly the kind of thing that belongs in a post, not buried in a commit. Closing it is a Google-callback interstitial, and it's the natural next increment.</p>
<p>Also deliberately out of scope: passwordless passkey login (this is second-factor only), TOTP as an alternate factor, and admin-mandated enforcement.</p>
<hr />
<h2>An honest word on testing</h2>
<p>You cannot drive a real authenticator from a test. The biometric and the secure chip aren't scriptable — there is no way to make Face ID say yes inside <code>bun test</code>. So <strong>there is no real end-to-end passkey test, and nothing in this codebase claims to be one.</strong></p>
<p>What <em>is</em> tested is the state machine we own: pending-token issuance, expiry, and single-use; the ownership guard; counter updates; recovery-code consumption; step-up gating; and — added after a reviewer pointed out it had never actually executed — that the rate limit really binds on the doubly-nested <code>/auth/2fa</code> routes. The <code>@simplewebauthn</code> verify is mocked at exactly one boundary (it's injected as a default argument precisely so a test can replace it), and the <strong>recovery-code path is the honest no-mock end-to-end</strong> for both login and disable — a full round trip through the route machine without a real authenticator.</p>
<p>The passkey-assertion <em>success</em> path is covered at the service layer and named honestly everywhere it appears. The thing left for a human is a five-minute manual smoke on <code>localhost</code> (a WebAuthn secure context): real Face ID enroll → log out → log in with the passkey → disable with step-up. That's the one part the mocks can't prove, and it's flagged as such.</p>
<p>I'd rather tell you the seam exists than paper over it with a test that pretends to drive a sensor it can't.</p>
<hr />
<h2>What you can learn from this</h2>
<p>Trace Mara through the second factor and a few ideas keep doing the work.</p>
<p><strong>Nothing secret has to travel.</strong> A password is checked by sending it; a passkey is checked without ever moving the private key off the phone. That one difference is the entire reason a passkey survives a phishing site and a typed code doesn't — the browser binds the signature to the origin, and a fake site has nothing to relay.</p>
<p><strong>The first factor is only load-bearing if the server aims the second.</strong> The userId for the second factor comes from server-side state, never the request — at the pending token, at every login route, at step-up. Get that one rule wrong and the password becomes decoration; an attacker passes <em>their</em> factor against <em>your</em> account. Four places, one invariant.</p>
<p><strong>The best defenses are still holes you never built.</strong> There's no "reset my passkey" because the server never held the key — so there's no reset endpoint to abuse. "2FA is on" is derived from a row count, so there's no boolean to drift. A stolen session can't strip 2FA, because the off switch demands a fresh factor the session-thief doesn't have. You don't add those defenses; you build a shape with no room for the attack.</p>
<p>And the whole machine, in three beats:</p>
<pre><code class="language-plaintext">The password proves who you are, once — and a passkey-holder gets no session for it alone.
The device signs a fresh challenge your face unlocked but never sent, and the server checks it aims at the right account.
Lose the phone and ten one-time codes get you back; steal the session and the lock still won't come off.
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Auth from scratch]]></title><description><![CDATA[With all this fire and forget vibe coding around, it's time for some back to basics so here is a Claude generated authentication for my pet project (redline), but written in a maintainable and secure ]]></description><link>https://featuringcode.com/auth-from-scratch</link><guid isPermaLink="true">https://featuringcode.com/auth-from-scratch</guid><category><![CDATA[authentication]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Wed, 24 Jun 2026 18:43:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/2f132625-dfee-487b-8b47-fc79d5972699.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>With all this fire and forget vibe coding around, it's time for some back to basics so here is a Claude generated authentication for my pet project (redline), but written in a maintainable and secure way that can be understood by humans. Find the feature <a href="https://github.com/mmswi/miniSocialApp/tree/feature/auth">here</a>. If you want to go step by step, go though each commit and look at the explanatory-documents folder as each commit is explained there.</p>
<p>This will take you through the auth <em>core</em> — sessions, login, account linking, password reset — it's built on top of a few vetted packages: <a href="https://www.npmjs.com/package/argon2id"><code>argon2id</code></a> for hashing, <a href="https://www.npmjs.com/package/arctic"><code>arctic</code></a> for the Google OAuth (Open Authorization) flow, <a href="https://www.npmjs.com/package/bullmq"><code>BullMQ</code></a> for the email queue.</p>
<p>So follow along by tracing one person through the whole machine.</p>
<p>Her name is Mara. She signs up with an email and a password. She logs in. She signs in with Google instead. She verifies her email. Weeks later she forgets her password and resets it. Eventually she links her Google login to her existing account.</p>
<p>Follow Mara, and the rest falls into place.</p>
<p>Two themes will keep coming back, so watch for them:</p>
<ul>
<li><p>The interesting question is almost never <em>"can this be stolen?"</em> — usually the answer is yes. It is <em>"once it is stolen, what can the thief actually do, and can you turn it off?"</em></p>
</li>
<li><p>The best defenses are not features you add. They are holes you never built. Several times in this post, a whole class of attack just <em>cannot happen</em> because of the shape of the thing — not because of a check.</p>
</li>
</ul>
<p>Let's go.</p>
<hr />
<h2>First: never store the password</h2>
<p>Mara types <code>hunter2-but-longer</code>.</p>
<p>The naive version stores exactly that:</p>
<pre><code class="language-plaintext">users
  email           password
  mara@work.test  hunter2-but-longer
</code></pre>
<p>This is bad.</p>
<p>The day that table leaks — and tables leak — every password is just sitting there. In plain text. Reusable on every other site Mara owns.</p>
<p>So we never store the password.</p>
<p>We store a one-way transformation of it. A hash.</p>
<p>A hash is a function you can run forwards but not backwards.</p>
<p>You can turn <code>hunter2-but-longer</code> into a hash.</p>
<p>You cannot turn the hash back into <code>hunter2-but-longer</code>.</p>
<p>That is the whole point.</p>
<pre><code class="language-ts">// src/auth/password.ts
export const hashPassword = (plainPassword: string): Promise&lt;string&gt; =&gt;
  hash(plainPassword, ARGON2_OPTIONS)
</code></pre>
<h3>Why argon2id, and not just any hash</h3>
<p>Not every hash is safe for passwords.</p>
<p><code>sha256(password)</code> is a hash (SHA-256, the Secure Hash Algorithm). It is also a terrible password hash.</p>
<p>Because it is <em>fast</em>. A modern GPU (Graphics Processing Unit) computes billions of sha256 per second. An attacker with your leaked table just tries billions of guesses until one matches.</p>
<p>A password hash should be <em>slow</em>. On purpose. Slow enough that a billion guesses becomes impractical.</p>
<p><code>argon2id</code> is slow on purpose. It is also <em>memory-hard</em> — each guess must allocate real memory, which GPUs hate:</p>
<pre><code class="language-ts">const ARGON2_OPTIONS = {
  memoryCost: 19456, // 19 MiB per hash
  timeCost: 2,
  parallelism: 1,
} as const
</code></pre>
<p>19 MiB per attempt. That is nothing for one honest login. It is a wall for an attacker doing billions.</p>
<h3>Why a fresh salt matters</h3>
<p>Run the same password through the same hash twice and you would normally get the same output.</p>
<p>That is a problem.</p>
<p>If two users both pick <code>password123</code>, identical hashes give it away. And an attacker can precompute a giant table of <code>hash → password</code> once and reuse it against everyone — a "rainbow table."</p>
<p>A salt kills both.</p>
<p>A salt is a random value mixed into each hash, so the same password hashes differently every time.</p>
<p>argon2 generates a fresh random salt per call and stores it <em>inside</em> the hash string. You do not manage it. You can watch it work:</p>
<pre><code class="language-ts">const first = await hashPassword('same input')
const second = await hashPassword('same input')
expect(first).not.toBe(second) // same password, different hash
</code></pre>
<p>Same input. Different output. Every time.</p>
<p>To check Mara's password at login, argon2 reads the salt and parameters back out of the stored hash and recomputes:</p>
<pre><code class="language-ts">export const isPasswordCorrect = (storedHash: string, plainPassword: string): Promise&lt;boolean&gt; =&gt;
  verify(storedHash, plainPassword)
</code></pre>
<p>The comparison is constant-time, so an attacker cannot learn the password by measuring how long the check takes.</p>
<p>So, where are we: the password runs through the server and never further than the login handler. A string goes in, a longer string comes out — the hash, with salt and params baked in. The hash gets stored; the password never does. A new salt is computed on every signup, a verify on every login. And what gets handed to the next step is a verified identity: <em>this really is Mara.</em></p>
<hr />
<h2>Second: the cookie is a key, so store the lock, not the key</h2>
<p>Mara is who she says she is. Now the server needs to remember that on her <em>next</em> request.</p>
<p>It hands her a token. A long random string. She stores it in a cookie and sends it back every time.</p>
<pre><code class="language-ts">// src/auth/tokens.ts
export const generateToken = (): string =&gt; randomBytes(32).toString('base64url')
</code></pre>
<p>32 random bytes. 256 bits of entropy. Nobody guesses that.</p>
<p>Here is the tempting, naive version:</p>
<pre><code class="language-plaintext">sessions
  token                         userId
  k9f2...rawTokenInTheClear...  mara-uuid
</code></pre>
<p>Store the token. Mara sends it, you look it up, match found, she's in.</p>
<p>This is bad for the same reason plaintext passwords are bad.</p>
<p>That token <em>is</em> the key to Mara's account. Anyone holding it is Mara. And here you have copied every key into a database — the one thing most likely to leak.</p>
<p>So we do the same trick as passwords.</p>
<p>We store the <em>hash</em> of the token, not the token.</p>
<pre><code class="language-ts">export const hashToken = (rawToken: string): string =&gt;
  createHash('sha256').update(rawToken).digest('hex')
</code></pre>
<p>The raw token lives in exactly one place: Mara's cookie.</p>
<p>The database stores only <code>sha256(token)</code>:</p>
<pre><code class="language-ts">// src/db/schema.ts — sessions.id is the HASH, not the raw token
id: text('id').primaryKey(), // sha256(rawToken)
</code></pre>
<p>Now think about a database leak.</p>
<p>The attacker gets a column of sha256 hashes.</p>
<p>To turn one back into a working cookie, they would have to reverse sha256. They cannot.</p>
<p>A leaked sessions table is a list of useless fingerprints.</p>
<p>This "store the hash, never the secret" move is the spine of this whole project. We will do it again for email-verification tokens and again for password-reset tokens. Same shape every time: the raw secret rides in the link or the cookie; the database keeps only its sha256.</p>
<h3>Why sha256 here, but argon2id for passwords</h3>
<p>This looks like a contradiction. We just said sha256 is a bad password hash. Now we use it for tokens. On purpose.</p>
<p>The difference is what is being hashed.</p>
<p>A password is low-entropy. Humans pick <code>summer2024</code>. It is <em>guessable</em>, so the hash must be slow to make guessing expensive.</p>
<p>A token is 256 bits of pure randomness. It is <em>not guessable</em> — there is nothing to brute-force. So a fast hash is fine, and fast is good, because we verify tokens far more often than passwords.</p>
<p>Slow hash for the guessable thing.</p>
<p>Fast hash for the unguessable thing.</p>
<h3>Why a server session, and not a JWT</h3>
<p>Mara has a cookie. The server checks it on every request. But what is actually <em>in</em> that cookie? There are two designs, and they differ in one thing: where the identity facts live, and how the server trusts the cookie each time.</p>
<p>Think of it as a coat-check ticket versus a signed ID card.</p>
<p><strong>Our design — a coat-check ticket.</strong> Mara's cookie holds a random, meaningless string:</p>
<pre><code class="language-typescript">k9f2x7q...   (43 random chars — says nothing on its own)
</code></pre>
<p>It is a ticket number. To learn who it belongs to, the server takes it to the back room and looks up what it points to:</p>
<pre><code class="language-typescript">cookie = k9f2x7q...
↓  hash it: sha256(k9f2x7q...)
↓  look that hash up in the sessions table
Postgres:  sha256(k9f2...) → { userId: mara, expiresAt: ... }
</code></pre>
<p>The facts live in the database. The cookie is just a pointer to them.</p>
<p><strong>The alternative — a JWT (JSON Web Token), a signed ID card.</strong> A JWT cookie holds the facts <em>themselves</em>, encoded, with a signature:</p>
<pre><code class="language-typescript">eyJhbGci...  .  eyJ1c2VySWQiOiJtYXJhIn0  .  3aF9c...sig
   header              payload                signature
</code></pre>
<p>Base64-decode that middle part and it literally reads:</p>
<pre><code class="language-typescript">{ "userId": "mara", "exp": 1699999999 }
</code></pre>
<p>Anyone can read it. A JWT is not encrypted — only <em>signed</em>. The server wrote <code>userId: mara</code> and stamped it with a signature only its secret can produce. So on each request the server does no lookup. It recomputes the signature — an HMAC, a Hash-based Message Authentication Code — over <code>header.payload</code> with its secret and checks it matches:</p>
<pre><code class="language-typescript">cookie = eyJ...payload...sig
↓  HMAC(secret, header.payload) == sig ?
↓  yes → trust the payload. userId is mara.
(no database — the proof rides inside the card.)
</code></pre>
<p>The facts live inside the token. The server stores nothing per session, only its one secret.</p>
<p>One thing to be explicit about, because it is easy to conflate: hashing our session token at rest has <em>nothing</em> to do with JWT. We hash because we <em>store</em> the token, and a stored copy should be useless if the database leaks. A JWT is never stored — there is nothing to hash. Different concern.</p>
<pre><code class="language-typescript">Ours:  random token  → STORE its hash, then look it up    (stateful)
JWT:   signed facts   → STORE nothing, just verify math     (stateless)
</code></pre>
<h3>But can't someone just steal the cookie?</h3>
<p>Yes. And here is the honest part: a stolen cookie and a stolen JWT are <em>exactly</em> as bad as each other.</p>
<p>Both are <em>bearer tokens</em>. Whoever holds it, is Mara. Steal her session cookie, or steal her JWT, and the thief is Mara until something stops them.</p>
<p>Our design is not more theft-resistant. A stolen cookie is a stolen cookie.</p>
<pre><code class="language-typescript">Can it be stolen?             ours: yes        JWT: yes      (identical)
Can you kill it once stolen?  ours: instantly  JWT: not until it expires
</code></pre>
<p>So the interesting question is not whether the token can be stolen — both can. It is whether, once it is stolen, you can turn it off. (Remember the theme.)</p>
<p>One word: revocation.</p>
<p>You might assume a JWT can be logged out. It mostly cannot.</p>
<p>A signed JWT is valid until it expires, because <em>nothing is checking a list</em>. The proof is self-contained. To kill it early you have to bolt a denylist back on — which quietly re-adds the database lookup you went stateless to avoid.</p>
<p>A server session is the opposite. Logout is a <code>DELETE</code>:</p>
<pre><code class="language-typescript">// src/auth/session.ts
export const revokeSession = (rawToken: string): Promise&lt;void&gt; =&gt;
  revokeBySessionId(hashToken(rawToken))
</code></pre>
<p>The row is gone. The next request with that cookie finds nothing. Mara is out. Instantly. Everywhere that token was used.</p>
<p>For a document-review tool where people share access and need to <em>really</em> be removed, instant revocation is worth one lookup.</p>
<p>Here is the honest tradeoff, stated once so I don't have to keep restating it: if you were building a fleet of stateless services that must verify identity with zero shared database — many microservices, edge functions — a signed JWT earns its keep, and you accept weaker logout. We are a single app with one Postgres and one Redis. Instant revocation matters more than shaving a lookup. So: server sessions. Pick the one that fits.</p>
<hr />
<h2>Third: Redis sits in front, but Postgres is the truth</h2>
<p>One lookup per request sounds cheap. At scale it is not — it is a database round-trip on <em>every</em> call, including every WebSocket message later.</p>
<p>So we cache it.</p>
<p>But caching identity is dangerous if you do it naively.</p>
<p>The naive version: store sessions <em>only</em> in Redis.</p>
<p>Fast. Also fragile. Redis is memory; restart it and everyone is logged out. And it makes revocation murky — which copy is the truth?</p>
<p>We avoid that by being strict about one thing:</p>
<pre><code class="language-typescript">Postgres is the source of truth.
Redis is only a fast shortcut in front of it.
</code></pre>
<p>This is a read-through cache. The flow:</p>
<pre><code class="language-typescript">request with cookie
↓
check Redis for this session          (fast path, in-memory)
↓ miss
read Postgres                          (source of truth)
↓ found + not expired
copy it into Redis with a short TTL    (time-to-live, so the next read is fast)
↓
return the user
</code></pre>
<pre><code class="language-typescript">// src/auth/session.ts — the miss path repopulates the cache
const [row] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1)
// ...
await redis.set(key, JSON.stringify(cacheValue), 'EX', ttl)
</code></pre>
<p>If Redis dies, nothing breaks. Every request just becomes a Postgres read again. Slower. Not broken.</p>
<h3>The one honest tradeoff: revoke-all vs the cache</h3>
<p>Single logout is instant. We hold the raw token, so we delete the row <em>and</em> delete that exact Redis key:</p>
<pre><code class="language-typescript">const revokeBySessionId = async (sessionId: string): Promise&lt;void&gt; =&gt; {
  await db.delete(sessions).where(eq(sessions.id, sessionId))
  await redis.del(cacheKey(sessionId))
}
</code></pre>
<p>"Log out everywhere" is different. We delete all of Mara's rows in Postgres immediately. But we do not hold every raw token, so we cannot bust every Redis key by hand.</p>
<p>A session cached a second before the revoke could still validate — until its cache entry expires.</p>
<p>So we keep that window short on purpose:</p>
<pre><code class="language-typescript">const CACHE_TTL_SECONDS = 60 // bounds how long a cached session can outlive a revoke-all
</code></pre>
<p>Sixty seconds. That is the tradeoff, stated plainly: single logout is instant; logout-everywhere is instant in Postgres and lags at most a minute in cache. We chose that over tracking every session key per user. For this app, a one-minute tail on "log out all devices" is fine. If it were a bank, it would not be. (This sixty-second lag comes back later, when a password reset tries to log out a thief.)</p>
<hr />
<h2>Fourth: a free defense you get just from the shape</h2>
<p>There is a subtle attack called session fixation.</p>
<p>The attacker plants a known session id on a victim <em>before</em> they log in, then rides that same id after the victim authenticates.</p>
<p>It only works if the session id stays the same across the login boundary.</p>
<p>Ours cannot.</p>
<p>We do not have a session <em>before</em> login to reuse. We <em>mint a brand-new one</em> at the moment login succeeds:</p>
<pre><code class="language-typescript">// createSession runs on successful login — fresh random token every time
const rawToken = generateToken()
</code></pre>
<p>A fresh token on every login means there is no pre-login id to fix. The defense falls out of the design. We did not add a feature for it; we just never created the hole.</p>
<p>The good ones are often like that. The vulnerability is the thing you <em>didn't</em> build.</p>
<hr />
<h2>Assembling it: one request, four files, one round trip</h2>
<p>We have the parts. Now the assembly.</p>
<p>Mara clicks <strong>Sign in</strong>. Between that click and <em>Welcome back</em> there is one HTTP request, four files, and exactly one round trip.</p>
<p>Everything here runs on the <strong>server</strong>. The browser's whole job is to send a little JSON and, later, to hold a cookie.</p>
<p>Four steps, always in this order: <strong>validate → authenticate → mint → set cookie.</strong></p>
<p>Each hands the next a smaller, more-trusted thing. The body comes in as <code>unknown</code>. The validator turns it into a typed <code>{ email, password }</code>. The service turns that into a <code>userId</code>. The session turns the <code>userId</code> into a raw token. The cookie carries the token back to the browser.</p>
<p>The rest of this section is <em>why each step is shaped the way it is.</em></p>
<h3>Step one: the body is guilty until proven typed</h3>
<p>The request body is whatever the client sent. It could be anything.</p>
<p>So the first thing the route does is refuse to trust it:</p>
<pre><code class="language-typescript">const signupBody = z.object({
  email: z.string().email(),
  password: z.string().min(8, 'Password must be at least 8 characters.').max(200),
  name: z.string().trim().min(1).max(100).optional(),
})
</code></pre>
<p>If the email is malformed or the password is too short, the request dies here with a <code>400</code> and a field message. It never reaches the database.</p>
<p>The naive version skips this and lets the database be the validator — a <code>NOT NULL</code> blows up somewhere deep, and the client gets a <code>500</code> with a stack trace.</p>
<p>A <code>500</code> is a bug.</p>
<p>A <code>400</code> is an answer.</p>
<p>We want answers. After <code>parseOrThrow(signupBody, req.body)</code>, the <code>unknown</code> is gone. Everything past it is typed.</p>
<h3>Step two, signup: two rows, or none</h3>
<p>Mara is new. Signup has to create <em>two</em> things:</p>
<pre><code class="language-typescript">users      who Mara is        (id, email, emailVerified)
accounts   how Mara logs in   (provider='password', password_hash)
</code></pre>
<p>One identity. One credential. They only make sense together.</p>
<p>The bad version:</p>
<pre><code class="language-typescript">await db.insert(users)...      // succeeds
await db.insert(accounts)...   // throws
</code></pre>
<p>Now there is a user with no way to log in. A ghost. And the email is taken, so Mara can't sign up again either.</p>
<p>The fix is a transaction — both rows commit, or neither does:</p>
<pre><code class="language-typescript">user = await db.transaction(async (tx) =&gt; {
  const [createdUser] = await tx.insert(users).values({ email, name }).returning()
  await tx.insert(accounts).values({ userId: createdUser.id, provider: 'password', passwordHash })
  return createdUser
})
</code></pre>
<p>All-or-nothing. No ghosts.</p>
<h3>The duplicate-email race</h3>
<p>Two browser tabs. Mara double-clicks. Two identical signups arrive at almost the same instant.</p>
<p>The tempting guard is <em>check, then insert</em>:</p>
<pre><code class="language-typescript">const existing = await db.select()...where(eq(users.email, email))   // both see "no one"
if (existing) throw conflict()
await db.insert(users)...                                            // both insert
</code></pre>
<p>Both requests check <em>before</em> either inserts. Both see an empty table. Both proceed. Now there are two Maras.</p>
<p>This is a race, and you cannot win it with a check — there is always a gap between looking and acting.</p>
<p>So we don't look. We let the database's <code>UNIQUE</code> index on <code>email</code> be the judge. The first insert wins; the second violates the constraint and Postgres raises SQLSTATE <code>23505</code> — the SQL (Structured Query Language) standard's code for a unique-constraint violation. We catch exactly that:</p>
<pre><code class="language-typescript">const isUniqueViolation = (error: unknown): boolean =&gt; {
  if (typeof error !== 'object' || error === null) return false
  const { code } = error as { code?: unknown }
  return code === '23505'
}
</code></pre>
<p>The constraint is atomic in a way a check-then-insert can never be. One Mara, always — even under a double-click.</p>
<p>(What happens <em>after</em> we catch that <code>23505</code> is a story in itself — a duplicate signup is a privacy leak waiting to happen. Hold that thought; we close it once the email channel exists.)</p>
<h3>Step three: the cookie is the only thing that goes to the browser</h3>
<p>Authentication succeeded. We mint a session and write the cookie:</p>
<pre><code class="language-typescript">reply.setCookie('redline_session', rawToken, {
  httpOnly: true,                              // JavaScript can't read it — XSS (Cross-Site Scripting) can't steal it
  secure: env.NODE_ENV === 'production',       // https-only in prod, but http in local dev
  sameSite: 'lax',                             // not sent on cross-site POSTs — blunts CSRF (Cross-Site Request Forgery)
  path: '/',
  expires: session.expiresAt,                  // dies exactly when the session does
})
</code></pre>
<p>Three flags, three different attacks.</p>
<p><code>httpOnly</code> is for the cross-site <strong>script</strong> that tries to read <code>document.cookie</code>.</p>
<p><code>sameSite: 'lax'</code> is for the cross-site <strong>form</strong> that tries to POST as Mara.</p>
<p><code>secure</code> is for the network <strong>eavesdropper</strong> — off in dev only because there's no https there.</p>
<p>The raw token is the <em>only</em> secret that ever leaves the server. Everything else — the hash, the user row, the session row — stays put.</p>
<h3>Step four, later: the cookie becomes a user</h3>
<p>Mara loads a page. The browser attaches the cookie. <code>GET /auth/me</code> runs:</p>
<pre><code class="language-typescript">const rawToken = req.cookies[SESSION_COOKIE_NAME]
if (rawToken === undefined) throw unauthorized(...)      // no cookie → not signed in

const active = await getSessionUser(rawToken)            // Redis → Postgres read-through
if (active === null) throw unauthorized(...)             // expired or revoked → not signed in
</code></pre>
<p>It is the inverse of login. Login turned a <code>userId</code> into a token. <code>/me</code> turns the token back into a <code>userId</code>.</p>
<p>And logout <em>shrugs</em>:</p>
<pre><code class="language-typescript">if (rawToken !== undefined) await revokeSession(rawToken)   // kill the row + bust the cache
clearSessionCookie(reply)                                   // always
return reply.code(204).send()                               // always
</code></pre>
<p>Logout is <strong>idempotent</strong>. Log out twice, or log out with no session at all, and the answer is still <code>204</code>. Logout is a <em>cleanup</em> action, and a cleanup action that can fail is a worse experience than one that can't. There's no security in making "log out when you're already logged out" an error. So it isn't one.</p>
<hr />
<h2>The leak hiding in the error message</h2>
<p>Now Mara comes back a week later and logs in. This is where it gets interesting.</p>
<p>A login can fail two ways:</p>
<pre><code class="language-typescript">the email isn't registered
the password is wrong
</code></pre>
<p>The obvious thing is to say which:</p>
<pre><code class="language-typescript">404  no account with that email
401  wrong password
</code></pre>
<p>That is <strong>user enumeration</strong>, and it is a real leak.</p>
<p>Watch what an attacker does with it. They don't know who banks here, or whose documents are on redline. So they probe:</p>
<pre><code class="language-typescript">POST /auth/login  ceo@bigco.com  / anything   →  404   (not a user — move on)
POST /auth/login  mara@work.test / anything   →  401   (a user! now brute-force her)
</code></pre>
<p>The <code>404</code> vs <code>401</code> <em>is</em> the answer to "does this person have an account here?" — which on a private tool is itself sensitive.</p>
<p>So login gives <strong>one</strong> answer for both cases:</p>
<pre><code class="language-typescript">const invalidCredentials = (): never =&gt; {
  throw unauthorized('invalid_credentials', 'Email or password is incorrect.')
}
</code></pre>
<p>"Email <em>or</em> password." Same status, same body, whether the email is unknown or the password is wrong. The response stops being an oracle.</p>
<h3>The leak you can't see: timing</h3>
<p>Make the bodies identical and you've closed the <em>visible</em> leak. But there's a second one, and it hides in the clock.</p>
<p>Look at the honest two-branch code:</p>
<pre><code class="language-plaintext">no such account   →  return immediately
wrong password    →  run argon2id verify (~50ms), then return
</code></pre>
<p>The bodies match. The <em>timing</em> doesn't.</p>
<p>A <code>401</code> that comes back in 2ms means "no such account." A <code>401</code> that takes 50ms means "real account, wrong password." The attacker times the response and reads the difference. Same leak, through a side door.</p>
<p>The fix is to make the missing-account path do the same expensive work:</p>
<pre><code class="language-typescript">if (account === undefined || account.passwordHash === null) {
  await verifyAgainstDecoy(input.password)   // burn ~50ms against a throwaway hash
  return invalidCredentials()
}
</code></pre>
<p><code>verifyAgainstDecoy</code> runs a real argon2id verify against a hash of a dummy password. It always fails, but that's not the point — the point is it <em>takes the same time</em>. Now both paths cost ~50ms, and the clock says nothing.</p>
<p>This <strong>timing-equalization with a decoy hash</strong> is a move we'll make again at signup. Burn the same work on the path that has no real work to do, so the duration carries no signal.</p>
<p>Notice <code>account.passwordHash === null</code> sits in the <em>same</em> branch as "no account." A Google-only user has a row, but no password. The naive code finds the row, calls <code>verify(null, password)</code>, and argon2id throws on a null hash — a <code>500</code>. Which, to an attacker, is a third distinct answer. Folding <code>null</code> into the decoy branch keeps the three cases — no email, no password, wrong password — indistinguishable.</p>
<p>One honest limit while we're here: timing equalization is <em>approximate</em>, not perfect. A determined attacker with a clean network path and thousands of samples can still tease out a statistical difference. The strong mitigation against that isn't timing — it's the rate limiter, which caps how many samples they can take. We build that later.</p>
<hr />
<h2>Google sign-in, and the link you must not make</h2>
<p>There are three parties in this story, not two.</p>
<p>Mara's browser. Our server. And Google.</p>
<p>Password login was a conversation between two of them — the browser tells our server a secret, our server checks it. Google sign-in is a <em>flow</em> between all three, where our server never sees Mara's Google password at all.</p>
<p>The interesting part isn't the flow. arctic handles the steps. The interesting part is the very last decision: Mara shows up with a verified Google email, and there's <em>already an account here with that email</em>. Do we merge them?</p>
<p>Get that wrong and you hand one person's account to another.</p>
<p>Let's earn our way to that decision.</p>
<h3>The base flow: what actually happens</h3>
<p>Forget security for a minute and just watch Mara sign in.</p>
<p>The goal is simple: <strong>Google vouches for Mara to us, and we never see her Google password.</strong></p>
<p>But Google can't call our server out of the blue. The only thing that touches <em>both</em> Google and our server is <strong>Mara's browser</strong>, bouncing between them. So the whole flow is built out of browser redirects.</p>
<p>Three beats:</p>
<pre><code class="language-plaintext">1. Mara clicks "Sign in with Google."
   Our server bounces her browser over to Google.
   ↓
2. Mara logs in at Google and approves.
   Google bounces her browser back to us — carrying a code.
   ↓
3. Our server takes that code and calls Google directly:
   "who does this code stand for?"
   Google answers with Mara's identity.
</code></pre>
<p>That <code>code</code> is the pivot of the whole thing.</p>
<p>It's a <strong>one-time ticket</strong> Google hands out that means <em>"a real user just approved."</em> Step 3 redeems the ticket for Mara's actual identity — and that redemption is a <strong>server-to-server</strong> call, our server straight to Google, with no browser in the middle.</p>
<p>That's the entire flow. It works. It's also broken in two ways — and the two cookies we set on the way out (<code>state</code> and <code>code_verifier</code>, which the code spells <code>codeVerifier</code>) are the two patches. Everything else in this section is just those two patches.</p>
<pre><code class="language-plaintext">OUT     (our redirect  → Google):  state + code_challenge
BACK    (Google        → us):      code + state   (state echoed)
DIRECT  (our server    → Google):  code + code_verifier
</code></pre>
<p><code>state</code> makes a round trip. <code>code</code> comes back. <code>code_verifier</code> only ever leaves at the very end, and never through the browser. The two patches below are just <em>why</em> each value travels the way it does.</p>
<h3>The same flow, with real values</h3>
<p>Names blur together. Values stick. So let's give all four concrete (made-up but realistic) values and watch where each one physically <em>is</em> at every hop.</p>
<pre><code class="language-plaintext">state          = "x7Kp9w"             — random, WE make it
code_verifier  = "tZ7n_K2pQ9xR4mL8"   — random secret, WE make it
code_challenge = "E9f2b7c1aZ…"        — SHA-256 of the verifier, WE make it
code           = (doesn't exist yet)  — GOOGLE mints it in Hop 2
</code></pre>
<p>Three of the four exist <em>before we ever contact Google</em> — we make them. <code>code</code> is the odd one out. This is the bit that trips people up: <strong>we never send Google a</strong> <code>code</code><strong>.</strong> We send <code>state</code> and <code>code_challenge</code>; Google hands a <code>code</code> <em>back</em>.</p>
<p><strong>Hop 1 — we send Mara's browser to Google.</strong></p>
<p>Not to our callback (that's the trip <em>back</em>, in Hop 3). We build a URL to <em>Google's</em> sign-in endpoint and redirect her there:</p>
<pre><code class="language-plaintext">302 → https://accounts.google.com/o/oauth2/v2/auth
        ?client_id=redline.apps.googleusercontent.com
        &amp;redirect_uri=https://redline.app/auth/google/callback
        &amp;response_type=code
        &amp;scope=openid email profile
        &amp;state=x7Kp9w               ← in the URL
        &amp;code_challenge=E9f2b7c1aZ…  ← in the URL (the HASH)
        &amp;code_challenge_method=S256
</code></pre>
<p>In that <em>same response</em>, we set two cookies on Mara's browser:</p>
<pre><code class="language-plaintext">Set-Cookie: g_state=x7Kp9w;              HttpOnly
Set-Cookie: g_verifier=tZ7n_K2pQ9xR4mL8; HttpOnly
</code></pre>
<p>So right now, here's where everything is:</p>
<pre><code class="language-plaintext">URL → Google (public):    state, code_challenge(the hash)
Mara's cookies (httpOnly): state, code_verifier(the secret for that hash)
</code></pre>
<p><code>code_verifier</code> (the secret) sits in a cookie and appears in <strong>no URL</strong>. Only its hash travelled. That one split is all of <strong>PKCE</strong> (<strong>Proof Key for Code Exchange</strong> — is pronounced "<strong>pixie</strong>.").</p>
<p><strong>Hop 2 — Google's turn.</strong></p>
<p>Google shows Mara the consent screen; she clicks Allow. Google now:</p>
<pre><code class="language-plaintext">holds  state          = x7Kp9w        → will echo it back
files  code_challenge = E9f2b7c1aZ…   → pinned to the code below
mints  code           = 4/0AfJ…       → a fresh one-time ticket
</code></pre>
<p>What Google never received: <code>code_verifier</code>. It only has the hash.</p>
<p><strong>Hop 3 — Google sends Mara's browser back. <em>This</em> is the callback:</strong></p>
<pre><code class="language-plaintext">302 → https://redline.app/auth/google/callback
        ?code=4/0AfJ…       ← Google's ticket
        &amp;state=x7Kp9w       ← our state, echoed back unchanged
</code></pre>
<p>Mara's browser still carries the Hop-1 cookies, so our server now holds both:</p>
<pre><code class="language-plaintext">from the URL:     code = 4/0AfJ…     state = x7Kp9w
from the cookies: verifier = tZ7n…   state = x7Kp9w
</code></pre>
<p><strong>Gate 1 — our server checks:</strong></p>
<pre><code class="language-plaintext">state in URL (x7Kp9w)  ===  state in cookie (x7Kp9w) ?
yes → this callback answers the sign-in WE started. Continue.
</code></pre>
<p><strong>Hop 4 — we redeem the code, server-to-server (no browser):</strong></p>
<pre><code class="language-plaintext">POST https://oauth2.googleapis.com/token
        code=4/0AfJ…
        code_verifier=tZ7n_K2pQ9xR4mL8   ← secret's first time out
        client_id=…  client_secret=…
        grant_type=authorization_code
</code></pre>
<p><strong>Gate 2 — Google checks:</strong></p>
<pre><code class="language-plaintext">SHA-256("tZ7n_K2pQ9xR4mL8")          = E9f2b7c1aZ…   (recomputed now)
the challenge Google filed in Hop 2  = E9f2b7c1aZ…
match → whoever holds the verifier started this. Here's the id token.
</code></pre>
<p>Where each value lived, all at once:</p>
<p>With those values in hand, both attacks below are easy to see:</p>
<pre><code class="language-plaintext">Thief reads the Hop-1 URL → gets state=x7Kp9w, code_challenge=E9f2b7c1aZ…
   state: useless without Mara's httpOnly cookie (can't be written).
   challenge: a hash — won't reverse to tZ7n_K2pQ9xR4mL8.

Thief steals code=4/0AfJ… from a leaked Hop-3 URL → stuck at Gate 2:
   it demands code_verifier=tZ7n_K2pQ9xR4mL8, which only ever lived
   in Mara's httpOnly cookie and never rode any URL.
</code></pre>
<p>The next two sections are just those two attacks, spelled out.</p>
<h3><code>state</code>: proving the callback answers the request we started</h3>
<p>Look at step 2 again. Google sends Mara's browser back to us with a <code>code</code> in the URL (Uniform Resource Locator):</p>
<pre><code class="language-typescript">GET /auth/google/callback?code=…
</code></pre>
<p>The hole: <em>anyone</em> can point Mara's browser at that callback URL.</p>
<p>So an attacker starts <em>their own</em> Google sign-in and gets a valid <code>code</code> for <em>their</em> account. They don't redeem it. They trick Mara's browser into hitting our callback with <em>their</em> code:</p>
<pre><code class="language-typescript">/auth/google/callback?code=THE-ATTACKERS-CODE
</code></pre>
<p>If our server redeems whatever <code>code</code> shows up, it logs Mara into <strong>the attacker's account</strong> — and everything she uploads or comments now sits in an account the attacker can read. (This is CSRF — Cross-Site Request Forgery — a forged request riding in on the victim's browser.)</p>
<p>The fix: refuse any callback that can't prove it's finishing <em>the sign-in we started for this browser</em>. That is <code>state</code>'s whole job:</p>
<pre><code class="language-ts">if (query.data.state !== cookieState) {
  throw badRequest('oauth_state_mismatch', 'Google sign-in could not be verified.')
}
</code></pre>
<p>The attacker's forged callback carries the attacker's state — but Mara's browser never received a cookie for <em>that</em> sign-in. The two don't match. Rejected.</p>
<p><code>state</code> is the thread tying <em>who started this</em> to <em>who's finishing it</em>.</p>
<h3>PKCE: making a stolen <code>code</code> worthless</h3>
<p><code>state</code> proved the callback is ours. PKCE protects the <code>code</code> itself.</p>
<p>Remember how the <code>code</code> travels: Google → browser → us, sitting in a URL. <strong>URLs leak</strong> — server logs, browser history, a shoulder-surfed address bar. So assume the <code>code</code> <em>can</em> be stolen in transit. If a stolen <code>code</code> were enough to redeem, the whole thing falls over.</p>
<p>So redeeming a <code>code</code> has to require a <strong>second thing</strong> — a secret that only the browser which <em>started</em> this sign-in could hold. That second thing is <code>code_verifier</code>.</p>
<p>The trick is that there are two different roads to Google — one <em>through</em> Mara's browser, one a direct server-to-server HTTPS (HTTP Secure) call:</p>
<pre><code class="language-plaintext">Front channel — through the browser (redirect URLs — these leak)
Back channel  — our server → Google directly (never via browser)
</code></pre>
<p>PKCE keeps the secret off the leaky road. We generate the secret, send Google only a <em>hash</em> of it up front, and reveal the secret itself only on the private road, at the very end:</p>
<pre><code class="language-plaintext">code_verifier   = a long random secret   (stays in our cookie)
code_challenge  = SHA-256(code_verifier) (just a hash — shareable)
</code></pre>
<p>The verifier is the key. The challenge is a <em>photo of the key's shape</em> — enough to check a match, not enough to cut a copy.</p>
<p><strong>Up front</strong>, as the sign-in starts, we compute the challenge locally and put it in the redirect URL:</p>
<pre><code class="language-typescript">const url = google.createAuthorizationURL(state, codeVerifier, GOOGLE_SCOPES)
</code></pre>
<p>That URL carries the <strong>challenge</strong>, never the verifier. Mara's browser delivers it to Google (front channel), and Google files it against this request before handing back a <code>code</code>.</p>
<p><strong>At the end</strong>, to redeem the <code>code</code>, our server makes its one direct call — sending the <strong>verifier</strong> for the first and only time:</p>
<pre><code class="language-typescript">const tokens = await google.validateAuthorizationCode(code, codeVerifier)
</code></pre>
<p>This is the back channel. Google computes <code>SHA-256(verifier)</code> and checks it equals the challenge it filed earlier. Match → tokens. Mismatch → rejected.</p>
<p>So the two halves reach Google by two different roads, and only the harmless one ever rides the leaky channel:</p>
<pre><code class="language-plaintext">challenge (hash)    → browser → Google   — up front
verifier  (secret)  → server  → Google   — only at the very end
</code></pre>
<p>Now the attacker who scraped the <code>code</code> from a leaked URL tries to redeem it. Google asks for the verifier:</p>
<pre><code class="language-plaintext">Not in a URL   — it was never put in one.
Not in reach   — the cookie is httpOnly, on Mara's machine.
Not reversible — SHA-256 doesn't run backwards.
</code></pre>
<p>No verifier, no exchange. The stolen <code>code</code> is dead. A code without its verifier is a key without its cut.</p>
<h3>What comes back: an id token, not a password</h3>
<p>Look closer at that <strong>id token</strong>. It's a JWT whose payload is a small bag of facts about Mara, signed by Google:</p>
<pre><code class="language-typescript">{ "sub": "11029...", "email": "mara@work.test", "email_verified": true, "name": "Mara" }
</code></pre>
<p><code>sub</code> is Google's permanent, unique id for Mara — stable even if she changes her email. That's what we store as her Google identity, not the email.</p>
<p>We got this token directly from Google's token endpoint, over TLS (Transport Layer Security). So we <em>decode</em> the claims rather than re-verifying the signature — the secure channel already proved where it came from:</p>
<pre><code class="language-typescript">rawClaims = decodeIdToken(tokens.idToken())
</code></pre>
<p>Then we parse it strictly. <code>email_verified</code> must be a real boolean. If Google ever sent something surprising, the parse fails and the whole sign-in fails — closed:</p>
<pre><code class="language-typescript">const googleIdTokenClaims = z.object({
  sub: z.string().min(1),
  email: z.string().email(),
  email_verified: z.boolean().optional(),
  // …
})
</code></pre>
<p><strong>Fail-closed</strong> is the rule whenever the question is "is this email proven?" A wrong "no" is an inconvenience. A wrong "yes" is the bug in the next section.</p>
<h3>The decision: create, link, or refuse</h3>
<p>Now we have a verified identity in hand. Three cases.</p>
<p><strong>Case 1 — a returning Google user.</strong> The <code>sub</code> already maps to one of our users. Found it, open a session, done. The common path.</p>
<p><strong>Case 3 — a brand-new person.</strong> No Google identity, and no local account owns this email. Create the user and the Google credential together, in one transaction. Google already verified the email, so the new user inherits that.</p>
<p>I'm skipping Case 2 on purpose. Case 2 is the whole point.</p>
<h3>Case 2: the email is already taken</h3>
<p>Mara signed up weeks ago with <code>mara@work.test</code> and a password. Today she clicks "Sign in with Google," and her Google email is the <em>same address</em>.</p>
<p>No Google identity yet. But a user row already owns <code>mara@work.test</code>.</p>
<p>The tempting move:</p>
<blockquote>
<p>Google says the email is verified, and it matches. Link them. Log her in.</p>
</blockquote>
<p>Here's why that's a door you must not open.</p>
<p><code>mara@work.test</code> being in our <code>users</code> table does <strong>not</strong> mean Mara put it there.</p>
<p>Our signup lets anyone type any email. It doesn't prove ownership. So picture this, before Mara ever shows up:</p>
<pre><code class="language-plaintext">1. Attacker signs up with email = mara@work.test, password = (their own).
   Our users table now has a row for mara@work.test. emailVerified = false.
   (It was never proven — nobody clicked a link.)

2. The real Mara later clicks "Sign in with Google."
   Google says: mara@work.test, email_verified = true. It matches!

3. Naive rule auto-links Google to that existing row.
   Mara is now sharing an account with the attacker —
   who still knows the password.
</code></pre>
<p>The attacker is now inside Mara's account. They planted the email; the verified Google login walked right into the trap.</p>
<p>So matching-and-Google-verified is <strong>not enough</strong>. The fix is to demand proof from <strong>both sides</strong>:</p>
<pre><code class="language-ts">const bothEmailsVerified = claims.emailVerified &amp;&amp; existingUser.emailVerified
if (!bothEmailsVerified) {
  throw conflict('account_exists', 'An account with this email already exists. Sign in with your password to link Google.')
}
</code></pre>
<p>Google-verified <em>and</em> the local account already verified. The attacker's planted row was never verified, so the guard refuses to link and the takeover never happens.</p>
<p>You might assume "verified email matches" is the linking rule. It is half of it. The other half is <em>whose</em> verified — and a local row you can't trust doesn't count.</p>
<p>There's a catch, and it's worth being honest about: when this guard first landed, <em>nothing could produce a verified local account.</em> There was no way to verify a password account yet, so every "I have a password account, now I'm adding Google" attempt hit the <code>409</code>. The guard was correct, but in practice refuse-only.</p>
<p>That's fine. Refusing safely while the verified state is unreachable beats linking unsafely. And it's exactly the gap the next section fills — the guard doesn't move; the world catches up to it.</p>
<p>A few limits worth naming before we move on. This is <strong>one provider</strong>: the whole module is Google-shaped — one <code>sub</code>, one id token, Google's verified-email semantics. A second provider (GitHub) doesn't hand you a reliable <code>email_verified</code>, and the linking rule would change. The refusal is a JSON <code>409</code> — fine for a backend, but a browser mid-redirect deserves a real "you already have an account, here's how to link" page; that's frontend work. And decode-don't-verify rests on one assumption: the id token came straight from Google over TLS. It does here. Accept an id token from somewhere you didn't fetch yourself and you must verify the signature.</p>
<hr />
<h2>Email verification: a claim is not a proof</h2>
<p>Signup asked Mara for an email. It never asked the email whether it wanted Mara.</p>
<p>Anyone can type anyone's address into a signup form. A row in our <code>users</code> table that reads <code>mara@work.test</code> is a <em>claim</em> — "someone said they own this" — not a <em>proof</em>. We just saw the damage when you treat the claim as proof: an attacker registers a victim's address, and a later real Google login links straight into the attacker's account.</p>
<p>Verification turns the claim into a proof. The mechanism is one link, clicked once.</p>
<p><code>users.emailVerified</code> is a single boolean. The job of this whole piece is to make sure it only ever flips to <code>true</code> for someone who can actually read mail at that address.</p>
<p>So we send something to the address and check whether it comes back. That something is a token — and it's the session token in a different hat. The moves are identical to the ones we already built:</p>
<pre><code class="language-plaintext">generate a long random token   — 32 bytes, unguessable
store only its sha256 hash      — the id column of email_verification_tokens
put the RAW token in the link   — the only copy that leaves the server
give it an expiry               — 24 hours
</code></pre>
<pre><code class="language-typescript">const rawToken = generateToken()
await db.insert(emailVerificationTokens).values({ id: hashToken(rawToken), userId, expiresAt })
</code></pre>
<p>The raw token rides in the email. The database keeps only <code>sha256(rawToken)</code>. A leak of that table hands an attacker a column of hashes that open nothing — the same reason session tokens are stored hashed.</p>
<p>The bad version makes the point: email a link like <code>?verify=user_42</code>. Now anyone verifies anyone by editing the URL. The token is long and random <em>because</em> it has to be the proof — unguessable and unforgeable, not just an id.</p>
<h3>The flow</h3>
<pre><code class="language-plaintext">Signup                                                          (server)
↓
issue token: store sha256(token), email the raw token in a link (server)
↓
Mara opens her inbox and clicks the link                        (client)
↓
GET /auth/verify?token=…                                        (server)
↓
sha256 the token, look it up, check expiry
↓
flip emailVerified = true, delete the token, redirect to the app
</code></pre>
<p>Server-side at every step except the click. The only thing stored is the hash, plus the flag once it flips.</p>
<h3>Single-use, and why the row is deleted</h3>
<pre><code class="language-typescript">await db.transaction(async (tx) =&gt; {
  await tx.update(users).set({ emailVerified: true }).where(eq(users.id, row.userId))
  await tx.delete(emailVerificationTokens).where(eq(emailVerificationTokens.id, id))
})
</code></pre>
<p>The flip and the delete commit together. A token that already verified someone no longer exists, so the same link can't be replayed into a second verification.</p>
<p>That deletion shapes the error messages. A verify fails two ways the user can tell apart:</p>
<pre><code class="language-plaintext">expired   — the link is older than 24 hours
invalid   — unknown, or already used
</code></pre>
<p>It cannot separate "already used" from "never existed" — a used token was <em>deleted</em>, so there's no row left to distinguish it. Both return <code>invalid_verification_token</code>. Keeping a tombstone that said "this one was used" would be an oracle: a way to probe which tokens once existed. Forgetting is safer.</p>
<h3>What this makes possible</h3>
<p>The flag isn't decoration.</p>
<p>The Google guard from the last section becomes <em>reachable</em>. A verified password account can now merge with a matching verified Google login — the exact precondition the takeover guard was waiting on. The guard didn't change; verification is what made its success path possible.</p>
<p>And a <strong>uniform signup response</strong> becomes possible — answering "check your email" identically whether the address is new or already registered, moving the real signal into the inbox instead of the HTTP status. That's the next section, and it's how we finally close the duplicate-signup leak I flagged earlier.</p>
<h3>When this is the wrong shape</h3>
<p><strong>A GET that changes state will be clicked by robots.</strong> The verify link is a plain URL in an email, so the browser fetches it with a GET — and so does every link scanner that touches the message first: corporate mail filters, antivirus, chat-app link previews. That bot's GET hits <code>/auth/verify</code>, flips the flag, and <em>deletes the token</em> before Mara clicks. She ends up verified, but her real click then shows "invalid or already used." It's confusing, not a hole — the scanner can only verify the address it was already trusted to handle. The standard fix is a two-step page: the GET renders a "Confirm" button and a POST does the mutation, which scanners don't follow. That's frontend work, and the wart is inherent to a link that both arrives by GET and changes state.</p>
<p><strong>Unverified users can still log in.</strong> We don't gate basic access on verification; an unverified Mara can sign in and look around. Verification gates <em>linking</em>, not the front door. That's a policy choice — an app moving money would gate more.</p>
<p>A token sent is a question: <em>can you read this?</em> A token returned is the answer: <em>yes.</em> The flag it flips is the difference between a claim and a proof.</p>
<hr />
<h2>How mail actually leaves the process</h2>
<p>Twice now I've said "we email the user." Never <em>how</em> an email leaves the process and lands in an inbox.</p>
<p>This is that part. And the interesting thing is that "send an email" means three completely different things depending on where the code is running.</p>
<h3>Start with the bad version</h3>
<p>The obvious way to send mail is to call the provider right where you need it:</p>
<pre><code class="language-typescript">// in the signup handler
await resend.emails.send({ from, to, subject, html })
</code></pre>
<p>Three things break.</p>
<p>Your <strong>tests</strong> now hit the network — or you mock the Resend SDK (Software Development Kit) in every test that touches signup.</p>
<p>A <strong>slow provider</strong> stalls the signup request, because the user is waiting on an API (Application Programming Interface) call to a third party.</p>
<p>And you've <strong>welded</strong> the signup flow to one vendor. Swap Resend for SES (Amazon's Simple Email Service) and you edit every call site.</p>
<p>The fix is a seam. One function, <code>sendEmail</code>, that every caller uses — and behind it, a transport chosen by environment.</p>
<h3>The seam</h3>
<p>Everything that sends mail calls exactly this:</p>
<pre><code class="language-ts">sendEmail({ to, subject, text })
</code></pre>
<p>A recipient, a subject, a body. No provider, no SMTP (Simple Mail Transfer Protocol), no SDK in sight.</p>
<p>Who calls it? Never the route directly. Two small <strong>templates</strong> do, each owning one message:</p>
<pre><code class="language-typescript">// src/auth/verify.ts — these decide WHAT to say
sendVerificationEmail(to, rawToken)   // "Confirm your email: &lt;APP_URL&gt;/auth/verify?token=…"
sendAccountExistsEmail(to)            // "You already have an account — just log in."
</code></pre>
<p>So the layers are clean:</p>
<pre><code class="language-plaintext">signupWithPassword        WHEN to send
  ↓
sendVerificationEmail     WHAT to say    (subject + body + the link)
  ↓
sendEmail                 WHERE to send  (picks the transport)
  ↓
the transport             HOW it travels (memory, or an SMTP socket)
</code></pre>
<p>Each layer knows only the one below it. The template doesn't know about SMTP. The route doesn't know about either.</p>
<h3>Three transports, chosen by NODE_ENV</h3>
<p>Here is the part that surprises people. <code>sendEmail</code> does three different things, and the only input that decides which is <code>NODE_ENV</code>:</p>
<pre><code class="language-plaintext">sendEmail(message)
├─ test         → push onto sentEmails[]        (no network at all)
├─ development  → SMTP → Mailpit                (localhost:1025, viewable at :8025)
└─ production   → SMTP → a real provider        (SMTP_HOST / USER / PASS from env)
</code></pre>
<pre><code class="language-typescript">export const sendEmail = async (message) =&gt; {
  if (env.NODE_ENV === 'test') {
    sentEmails.push(message)   // an array in memory
    return
  }
  await mailer().sendMail({ from: env.EMAIL_FROM, to: message.to, ... })
}
</code></pre>
<p><strong>Under test, there is no email.</strong> <code>sentEmails</code> is a plain array. A test signs up, then reads the array back to find the link that was "sent":</p>
<pre><code class="language-typescript">const link = sentEmails.find((m) =&gt; m.to === email)?.text.match(/token=(\S+)/)?.[1]
</code></pre>
<p>That's how the verification end-to-end test gets the token without a mail server. No network, no flake, instant.</p>
<p><strong>Under dev and prod, it's the same code</strong> — <code>mailer().sendMail(...)</code> over SMTP. The <em>only</em> difference is where the socket points, and that's pure config, not code. Dev points at <a href="https://hub.docker.com/r/axllent/mailpit"><strong>Mailpit</strong></a>: a docker container that speaks SMTP like a real server but never delivers anything — it just <em>catches</em> every message and shows it at <code>http://localhost:8025</code>. You sign up, switch tabs, and there's the verification email — click the link, you're verified. A real round trip, nothing leaving your laptop. Prod points those same env vars at a provider that <em>does</em> deliver.</p>
<h3>The connection is a singleton</h3>
<p><code>mailer()</code> doesn't build a new connection per email:</p>
<pre><code class="language-typescript">const mailer = () =&gt; {
  globalForMailer.mailer ??= createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, ... })
  return globalForMailer.mailer
}
</code></pre>
<p>One transporter per process, cached on <code>globalThis</code> so dev hot-reload reuses it instead of leaking a socket each reload — the same pattern as the DB (database) pool and the Redis client. It's also lazy: <code>createTransport</code> opens no socket; the connection happens on the first <code>sendMail</code>. So importing the module costs nothing — the import stays side-effect-free even though the module owns a connection. (Remember this singleton-on-globalThis shape; the queue uses it next.)</p>
<h3>A failed send must not fail the signup</h3>
<p>One rule the pipeline has to honor: the user row is already committed by the time we send. If the mail throws, the signup must still succeed.</p>
<p>Two reasons. First, it would be absurd to destroy an account because a third-party mail API hiccupped. Second — and sharper — on the duplicate path a thrown send would change the response, and that would reopen the enumeration oracle we're about to close: success returns <code>200</code>, a throw returns <code>500</code>, and now new-vs-taken is distinguishable again.</p>
<p>So both sends go through a best-effort wrapper:</p>
<pre><code class="language-typescript">const sendSignupEmail = async (send) =&gt; {
  try { await send() }
  catch (error) { console.error('[signup] email failed to send; signup still succeeds', error) }
}
</code></pre>
<p>Both call sites use it identically, so their failure behavior can't diverge. <code>sendEmail</code> itself still throws honestly; it's the <em>signup flow</em> that chooses to swallow, because only it knows the row is already there.</p>
<h3>The problem this creates</h3>
<p>The send is <strong>synchronous</strong>. Mara's signup waits for <code>sendMail</code> to finish. Mailpit is instant and the fail-soft wrapper means a slow provider won't <em>fail</em> the request — but it can still <em>slow</em> it.</p>
<p>Worse, look again at what fail-soft actually does on a failure: it logs and moves on. Signup succeeds — good. But the <strong>verification link is now gone.</strong> One attempt, it failed, nothing tries again. Mara sits at "check your email" forever, staring at an inbox that will never receive anything.</p>
<p>That wrapper protected the <em>signup</em> by sacrificing the <em>email</em>. The fix is a queue. That's the next section.</p>
<hr />
<h2>The email queue: a failed send must not lose the link</h2>
<p>The whole idea fits in one swap. Instead of <em>sending</em> the email at signup time, <em>enqueue</em> it:</p>
<pre><code class="language-plaintext">BEFORE   signup → sendEmail() → SMTP → (provider hiccups) → log + drop. Link lost.

AFTER    signup → enqueueEmail() → Redis. Returns in ~1ms.
                                     ↓
         worker → sendEmail() → SMTP → (hiccups) → BullMQ retries with backoff
                                     ↓ (eventually succeeds)
                                   delivered
</code></pre>
<p>Two properties fall out.</p>
<p><strong>Fast.</strong> <code>enqueueEmail</code> touches only Redis (local, ~1ms) and returns. Signup no longer waits on a third party at all — not even the instant Mailpit case.</p>
<p><strong>Durable.</strong> The job lives in Redis. If the first SMTP attempt fails, BullMQ re-runs it later — 5 attempts, exponential backoff. A transient provider outage costs minutes of delay, not a lost account.</p>
<h3>The producer side</h3>
<p><code>enqueueEmail</code> is the new seam the app calls. It replaces the direct <code>sendEmail</code> at the two template sites:</p>
<pre><code class="language-typescript">export const enqueueEmail = async (message) =&gt; {
  if (env.NODE_ENV === 'test') {
    await sendEmail(message)   // no worker under test — deliver inline (see below)
    return
  }
  await emailQueue().add(EMAIL_JOB_NAME, message)
}
</code></pre>
<p><code>emailQueue()</code> is the same singleton pattern as the DB pool, the Redis client, and the mailer — one <code>Queue</code> per process, cached on <code>globalThis</code>, built lazily so the import stays free. The job payload is just the <code>{ to, subject, text }</code> from before; the template still decides <em>what</em> to say, the queue is purely <em>how it travels now.</em></p>
<h3>The consumer side — and the one bug that matters</h3>
<p>The worker drains the queue and delivers each job. The handler is three lines, and one of them is load-bearing:</p>
<pre><code class="language-typescript">export const createEmailJobHandler =
  (deliver) =&gt;
  async (job) =&gt; {
    await deliver(job.data)   // throws → BullMQ retries. Do NOT catch here.
  }
</code></pre>
<p>Here is the trap. Your instinct, fresh off the fail-soft wrapper, is to wrap this in a try/catch — "emails shouldn't crash things." <strong>That instinct breaks the entire feature.</strong></p>
<p>A BullMQ job is retried <em>only if it rejects</em>. If the handler catches the SMTP error and returns normally, BullMQ sees a job that <strong>completed successfully</strong>, drops it, and never retries. You'd have built a queue that swallows failures exactly as silently as the thing it replaced — except now it <em>looks</em> robust. The whole point is that <code>sendEmail</code> throws, and that throw must travel all the way up to BullMQ.</p>
<p>So fail-soft does NOT live in the worker. It moved to wrap only the <em>enqueue</em>:</p>
<pre><code class="language-plaintext">producer (signup):   try { await enqueueEmail(...) } catch { log }   ← guards a rare Redis hiccup
worker (delivery):   await deliver(job.data)                          ← MUST reject so BullMQ retries
</code></pre>
<p>The producer still can't fail signup or reopen the enumeration oracle — but now it's guarding a local Redis write (almost never fails), not a flaky third-party SMTP call. The flaky part got moved to where retries live.</p>
<p>This is the one thing worth a unit test, and it's the cheapest test in the suite:</p>
<pre><code class="language-typescript">const handle = createEmailJobHandler(async () =&gt; { throw new Error('smtp down') })
await expect(handle({ data: message })).rejects.toThrow('smtp down')   // proves: rejects → will retry
</code></pre>
<p>Delivery is <em>injected</em> into the handler precisely so this test can drive a failure without a real mail server. We don't test BullMQ's backoff — that's the library's job. We test our wiring: a failed delivery rejects.</p>
<h3>Test mode still has no worker</h3>
<p>Under the test runner there's no worker process running, so a queued job would sit in Redis forever and the auth end-to-end test — which reads the verification link back out of that in-memory <code>sentEmails</code> array — would hang.</p>
<p>So <code>enqueueEmail</code> keeps the same <code>NODE_ENV</code> fork: under test it delivers <strong>inline</strong>, straight to <code>sentEmails</code>. The queue is real in dev and prod; in test it's transparent. The test never knew the queue arrived.</p>
<h3>Two ways to run the worker</h3>
<p>In production the worker is its <strong>own process</strong> — a separate container from the API. That's the entire point of the deploy constraint I set for this project: a persistent worker, not serverless, so a slow mail provider ties up a <em>worker</em>, never an API request handler.</p>
<pre><code class="language-plaintext">bun run src/worker.ts        # prod: the standalone consumer
</code></pre>
<p>But forcing two terminals in dev would silently break the signup→Mailpit flow the moment you forget the second one. So in development the API boots the same worker <strong>in-process</strong>:</p>
<pre><code class="language-ts">// server.ts, after listen()
if (env.NODE_ENV === 'development') {
  createEmailWorker()   // one `bun run api` still delivers email end to end
}
</code></pre>
<p>Same worker module either way. Dev co-locates for convenience; prod splits for isolation. The split is config-shaped, not a code fork.</p>
<h3>Pass connection config, not a client</h3>
<p>BullMQ's <code>connection</code> accepts either a live ioredis instance or a plain options object. Hand it an object:</p>
<pre><code class="language-ts">new Queue(EMAIL_QUEUE_NAME, { connection: queueConnectionConfig() })  // { host, port, maxRetriesPerRequest: null, … }
</code></pre>
<p>…and BullMQ builds and <strong>owns</strong> its own connections from it. Two payoffs.</p>
<p>First, a blocking worker needs its <em>own</em> connection — a <code>BRPOPLPUSH</code> that waits indefinitely for the next job would stall anything sharing the socket — so letting BullMQ create them means you never accidentally share one.</p>
<p>Second, and subtler: BullMQ bundles its <em>own</em> copy of ioredis, often a different version than the app's. A <code>Redis</code> <em>instance</em> from one copy is a different class than the other's, so passing your app's client across that line is a type error (and an <code>instanceof</code> hazard at runtime). A plain options object has no class identity — it crosses cleanly.</p>
<p>The one option that matters is <code>maxRetriesPerRequest: null</code>, and it lives <em>only</em> on BullMQ's connections. The app's shared client keeps ioredis's default, so an ordinary cache GET fails fast instead of hanging forever waiting on a dead Redis.</p>
<p>BullMQ itself is the deliberate choice here, same reasoning as <code>argon2id</code> and <code>arctic</code>: a reliable queue (atomic claim, visibility timeout, backoff, dead-lettering) is genuinely hard to get right, so you stand on a vetted primitive rather than hand-roll the core.</p>
<h3>When this is the wrong shape</h3>
<p><strong>The commit-and-enqueue gap.</strong> Signup commits the user row, then enqueues. If the process dies in the microsecond between, the row exists and no email job does — the same lost-link symptom, just far rarer. Closing it needs a <em>transactional outbox</em>: write the job to Postgres in the same transaction as the user, and a relay moves it to Redis. That's the next tier of durability and deliberately not built here — the failure window went from "any SMTP hiccup" to "a crash in a one-instruction window," which is the right amount of hardening for this slice.</p>
<p><strong>The raw token rides in the job.</strong> Only the token's <em>hash</em> is in the DB, but the queued job carries the raw token in its payload, briefly, in Redis. Acceptable — Redis is trusted infra and the job is dropped on delivery — but it's a wider blast radius than the DB, worth a note for a stricter threat model.</p>
<hr />
<h2>Uniform signup: the same answer, whether you exist or not</h2>
<p>Now I can pay off the debt I've flagged twice.</p>
<p>Login is enumeration-proof: one answer whether or not the email exists. Signup was not. It answered two different ways:</p>
<pre><code class="language-plaintext">POST /auth/signup  { new@x.com }     → 201 Created   + a session cookie
POST /auth/signup  { taken@x.com }   → 409 Conflict  { "error": "email_taken" }
</code></pre>
<p>The <code>409</code> says, out loud: <strong>someone already has this email here.</strong></p>
<p>That's the same leak we closed on <em>login</em> — just on the other door. An attacker with a list of ten thousand addresses POSTs each one and writes down the status code. Every <code>409</code> is a confirmed member.</p>
<p>On a public site, membership is no secret. On redline — a private tool for reviewing confidential documents — <em>who is on it</em> is the client list. The attacker reads it off the status codes, no password-guessing required.</p>
<h3>The fix: one answer for everyone</h3>
<p>Both paths now return the identical response:</p>
<pre><code class="language-plaintext">POST /auth/signup  { new@x.com }     → 200  "Check your email to finish signing up."
POST /auth/signup  { taken@x.com }   → 200  "Check your email to finish signing up."
                                        ↑ byte-for-byte the same
</code></pre>
<p>Same status, same body, no <code>error</code> field. The attacker's whole list comes back as <code>200</code>s. Nothing to write down.</p>
<p>The response can't carry the new-vs-taken signal now — so it goes where the inbox already is, a channel only the address owner can read:</p>
<pre><code class="language-plaintext">new email    → the verification email          ("Confirm your email: &lt;link&gt;")
taken email  → one new message                  ("You already have an account — just log in.")
</code></pre>
<p>In code, the duplicate that used to throw now sends instead:</p>
<pre><code class="language-typescript">} catch (error: unknown) {
  // Was: throw conflict('email_taken'). Now: tell the real owner by email, return the uniform 200.
  if (isUniqueViolation(error)) {
    await sendAccountExistsEmail(email)
    return
  }
  throw error
}
</code></pre>
<p>The attacker can hit the endpoint ten thousand times and can't read one of those inboxes. The signal is real; it just travels where they can't follow.</p>
<h3>The consequence: signup stops logging you in</h3>
<p>This is the part that isn't obvious, and it's the actual cost.</p>
<p>The old new-email response set a <strong>session cookie</strong> — signup logged you straight in. A uniform response can't.</p>
<p>Because the taken path has no session to grant. That account isn't yours; you proved nothing by typing its address. So watch what happens if only the new path keeps its cookie:</p>
<pre><code class="language-plaintext">new email    → 200 + Set-Cookie: session=...
taken email  → 200   (no cookie)
                ↑ the cookie is the tell again
</code></pre>
<p>The <em>presence of the cookie</em> becomes the difference the attacker was hunting. We'd have moved the leak from the status code into the <code>Set-Cookie</code> header and called it fixed.</p>
<p>So neither path sets one.</p>
<pre><code class="language-plaintext">before:  sign up ──────────────────► you're in
after:   sign up → check email → log in → you're in
</code></pre>
<p>Signup says "check your email," cookie-free, every time. One extra step on the happy path, paid because <em>"sometimes a session, sometimes not"</em> is exactly the oracle we're closing.</p>
<h3>Then why does Google signup still log you in?</h3>
<p>It does — and that looks like a contradiction. It isn't, because the two reasons password signup had to give up auto-login don't exist in the Google flow.</p>
<p><strong>No enumeration vector.</strong> To even reach the "do you already have an account?" branch, you must <em>first</em> complete the OAuth handshake — prove to Google you control that account. An attacker can't probe <code>alice@gmail.com</code> without being alice at Google. The password leak was an <em>unauthenticated</em> request revealing membership; by the time Google's callback runs, the request is already authenticated. No anonymous oracle, so withholding the session would buy nothing.</p>
<p><strong>The email is already proven.</strong> Password signup also couldn't log you in because the address was an unverified <em>claim</em>. Google sends <code>email_verified</code>, and a new user inherits it. Ownership is already proven, so there's no "check your email" to wait on.</p>
<p>So password signup withheld the session because the request was anonymous and the email unproven. Google signup is neither — so it logs you in, and that's correct, not an exception.</p>
<h3>The second tell: time</h3>
<p>Identical bodies aren't enough; the clock can still leak — the same timing problem login had.</p>
<p>argon2id hashing is deliberately slow (~50ms) and is the dominant cost of a signup. If the taken path skipped it — hashing only when it's about to create the account — it would reply ~50ms sooner, and the attacker just stops reading the body and starts timing:</p>
<pre><code class="language-plaintext">fast reply  → email is taken
slow reply  → email is new
</code></pre>
<p>So we hash on <strong>both</strong> paths, before the insert is even attempted. The taken path hashes a password it throws away — the same timing-equalizer move login makes with its decoy hash. A future reader who "optimizes" the hash to after the insert would silently reopen the oracle, which is why the code says so at that line.</p>
<h3>The whole flow</h3>
<pre><code class="language-plaintext">POST /auth/signup
↓
hashPassword(password)  ~50ms            (BOTH paths — timing equalizer)
↓
try: insert users + accounts in one tx
│
├── success ──► verification email ───────────┐
│                                             │
└── 23505  ──► sendAccountExistsEmail() ──────┤
                                              ↓
                200 "Check your email to finish signing up."
                (identical body, no cookie, either way)
</code></pre>
<p>Two paths in, one answer out. This is worth keeping only where <em>who is registered</em> is itself confidential — a private review tool, a medical portal, an internal admin. If membership isn't a secret — a forum, a game — a plain <code>409 "email taken"</code> is friendlier and costs new users no extra step. redline is the confidential kind, so it pays the step.</p>
<p>The endpoint tells everyone the same thing. The inbox tells the one person allowed to know. The clock tells no one anything.</p>
<hr />
<h2>Manual account linking: when you've already proven both ends</h2>
<p>Earlier, Mara's Google email matched her password email exactly, and the question was whether to auto-link. Now a different scenario.</p>
<p>Mara signed up with a password under <code>mara@work.test</code>. She wants to <em>also</em> sign in with Google — but her personal Google address is <code>mara@gmail.com</code>. A different email.</p>
<p>She has two ways to prove she's Mara. The system sees two strangers. Linking is making them one user: two <code>accounts</code> rows — one <code>password</code>, one <code>google</code> — pointing at one <code>users</code> row.</p>
<h3>Why the auto-link can't do this</h3>
<p>The auto-link rule was: when a Google sign-in arrives, link it to an existing local account <strong>only if the Google email is verified and matches</strong>. Run Mara through it:</p>
<pre><code class="language-plaintext">Google says: mara@gmail.com
Local has:   mara@work.test
</code></pre>
<p>The emails don't match. The auto-link looks for a local user with <code>mara@gmail.com</code>, finds none, and does the only safe thing it can for an anonymous caller: it creates a <em>brand-new, separate</em> account for <code>mara@gmail.com</code>. Now Mara has two accounts and no way to merge them.</p>
<p>That's not a bug. It's the auto-link being careful. Watch <em>why</em>.</p>
<h3>The asymmetry — this is the whole idea</h3>
<p>The auto-link runs for an <strong>anonymous</strong> caller. Someone just showed up with a Google login. The server cannot tell Mara from an attacker who registered <code>mara@work.test</code> first. So it trusts almost nothing: it links only when both sides carry a verified, matching email — proof that can't be faked by typing an address.</p>
<p>Manual linking runs for a caller who has <strong>already proven both ends</strong>:</p>
<pre><code class="language-plaintext">a valid session   → they own mara@work.test    (they're logged in)
a finished OAuth   → they control mara@gmail.com (Google just confirmed it)
</code></pre>
<p>Both halves are proven <em>before</em> the link function is even called. So it requires <strong>no email match at all</strong>. That's not a relaxed-security shortcut — it's the entire reason manual linking exists. The case the auto-link refuses (different email, or unverified) is exactly the case only a signed-in user should be allowed to resolve, by hand.</p>
<pre><code class="language-plaintext">Anonymous caller → trust almost nothing → match verified emails.
Authenticated caller who finished OAuth → both ends already proven → no match needed.
</code></pre>
<h3>The flow, and the one new cookie</h3>
<p>Mara is signed in. She clicks "Connect Google."</p>
<pre><code class="language-plaintext">GET /auth/google/link        (server: she has a session? if not → 401)
↓
mint state + PKCE, set handshake cookies   ← same as sign-in
set the link marker cookie                 ← the one new thing
↓
redirect to Google's consent screen        (client → google.com)
↓
Google redirects back: GET /auth/google/callback?code=…&amp;state=…
↓
verify state (CSRF), exchange code for claims   ← same as sign-in
↓
marker present? ── yes ──► re-check session ─► linkGoogleAccount(userId, claims) ─► redirect ?linked=google
              └─ no  ──► sign in (the normal path)
</code></pre>
<p>Google only lets us register <strong>one</strong> callback URL, so sign-in and link come back to the <em>same</em> <code>/auth/google/callback</code>. The callback has to know which one it is. That's what the marker cookie is for: the link route sets it; the callback reads it to pick the branch.</p>
<p>Notice the <strong>re-check session</strong> step. The marker only says "this was a link attempt." It does not say <em>who</em>. The session cookie says who — and it might have expired while Mara was on Google's consent screen. So the callback reads the session again, right then, and links to whoever is actually still signed in. The marker is a mode flag, never a trust token.</p>
<h3>The trap: a stale marker</h3>
<p>Here's a bug that hides in the one path no test can reach (the callback needs a live Google to exercise).</p>
<p><code>state</code> and <code>verifier</code> are written by <em>both</em> start routes, so they're always fresh. If the marker were written by <em>only</em> the link route, it could go stale:</p>
<pre><code class="language-plaintext">Mara clicks "Connect Google"   → marker set (lives 10 min)
Mara abandons it at Google
Mara clicks "Sign in with Google" → sets new state + verifier…
                                     …but the old marker is still in the jar
↓
callback sees the marker → treats a plain sign-in as a link → mislink or a spurious 401
</code></pre>
<p>The fix is to make <strong>both</strong> start routes write a <em>definitive</em> mode. The link route sets the marker; the plain sign-in route <strong>clears</strong> it. Now there's exactly one source of truth per flow, and the callback can never read a marker left over from an abandoned flow.</p>
<pre><code class="language-ts">/auth/google (sign-in)         → clearOAuthLinkCookie(reply)
/auth/google/link (linking)    → setOAuthLinkCookie(reply)
</code></pre>
<p>Because no test covers that branch, it has to be correct <em>by construction</em>. Two routes, two definitive writes, no leftover state.</p>
<h3>What linkGoogleAccount refuses</h3>
<p>No email match — but not <em>no</em> rules. A Google identity belongs to exactly one user, and that's enforced:</p>
<pre><code class="language-ts">already linked to THIS user      → idempotent no-op (a double-click is success, not an error)
already linked to ANOTHER user   → 409: you haven't proven you own that one
this user already has a Google   → 409: one Google identity per account
otherwise                        → insert; UNIQUE(provider, provider_uid) is the atomic backstop
</code></pre>
<p>The takeover-critical guarantee is that last index: even if two requests race past the in-code checks, the database lets exactly one <code>(google, sub)</code> row exist.</p>
<p>And the classic link-CSRF attack — an attacker gets an auth code for <em>their own</em> Google, then tricks a signed-in victim into hitting the callback with it, linking the attacker's Google into the victim's account — is already dead, from the same <code>state</code> check that stops sign-in CSRF. The attacker's code carries the attacker's state; the victim's browser never held it. The link never happens.</p>
<h3>When this is the wrong shape</h3>
<p>Only <strong>one direction</strong> is built: this links Google <em>to</em> a signed-in account. The reverse — setting a password on a Google-only account — is a separate small flow, not built yet. Errors are <strong>JSON mid-redirect</strong>: a link conflict returns a <code>409</code> body in the browser, not a friendly page — frontend work, same as the sign-in errors. And <strong>unlinking</strong> — removing a provider, and refusing to remove the <em>last</em> one, which would lock the user out — is its own feature with its own guard. Not here.</p>
<p>The auto-link trusts the math, because the caller is a stranger. The manual link trusts the session, because the caller already signed in. Both end at the same place: two proofs, one person.</p>
<hr />
<h2>Rate limiting: a leak somewhere else is a password list for here</h2>
<p>Attackers rarely guess one password ten thousand times.</p>
<p>They take ten thousand email/password pairs leaked from <em>some other</em> site and try each one once against your login. People reuse passwords, so a small fraction work. This is <strong>credential stuffing</strong>, and a login endpoint that answers as fast as you can ask is its ideal target.</p>
<p>The defense isn't a better password check. It's a budget: a client gets N tries per minute, then the door stops opening.</p>
<h3>What we count, and where</h3>
<p>One counter, per client IP (Internet Protocol address), per endpoint, per time window.</p>
<pre><code class="language-plaintext">key:    rate-limit for POST /auth/login from 203.0.113.7
value:  a number, starting at 0
expiry: 1 minute
</code></pre>
<p>Every request does two things in Redis: <code>INCR</code> the counter, and set <code>EXPIRE</code> to the window on the first hit. When the counter passes the limit, the next request gets a <code>429 Too Many Requests</code> before it ever reaches the handler. A minute later the key expires and the budget resets.</p>
<p>The limits, tuned to how often a real person does each thing:</p>
<pre><code class="language-plaintext">signup            5  / minute
login            10  / minute
verify           10  / minute
forgot-password   5  / minute
google (+ cb)    10  / minute
</code></pre>
<h3>Why the counter lives in Redis, not memory</h3>
<p>The app will be deployed with two app instances behind a load balancer.</p>
<p>If each instance kept its counts in its own memory, an attacker capped at 10/min on instance A would get a <em>fresh</em> 10/min the moment the balancer sent them to instance B — 20/min across two, 30 across three. The limit would dissolve the more you scaled.</p>
<p>So the counter lives in <strong>one shared Redis</strong>. Both instances <code>INCR</code> the same key, so the attacker's 11th request is the 11th no matter which instance handles it. It's the same shared-Redis property that makes sessions work across instances: one source of truth, many readers.</p>
<p>The limiter only touches routes that opt in. <code>/health</code> and <code>/ready</code> are left out deliberately — the load balancer hits them every few seconds to decide whether to route traffic; throttling them would make the platform think the app is down and pull it from rotation. The limiter guards the doors attackers push on, not the ones the infrastructure knocks on.</p>
<h3>The whole thing rests on one value: <code>req.ip</code></h3>
<p>The counter keys on <code>req.ip</code>. Every request that shares a <code>req.ip</code> shares a bucket. So per-IP limiting is only as correct as <code>req.ip</code> being <em>the real client</em>. Get that wrong and the limiter doesn't bend — it breaks, in one of two opposite directions.</p>
<p>Here's the break. In production the request never reaches you directly:</p>
<pre><code class="language-plaintext">client ──TCP──&gt; load balancer ──new TCP──&gt; your instance
</code></pre>
<p>The balancer doesn't pass the client's connection through. It opens its <em>own</em> TCP (Transmission Control Protocol) connection to your instance. So the socket your process sees belongs to the <em>balancer</em>, not the client.</p>
<p>If <code>req.ip</code> were the raw socket address, then in prod every request — from every user on earth — would arrive wearing the same IP. Now <code>signup</code> at 5/min is no longer 5 per user — it's 5 <em>total</em>, shared by the whole internet. The sixth signup anywhere trips it. The limiter has become a self-inflicted outage.</p>
<p>So how does the real client IP survive the hop? The balancer writes it into a header:</p>
<pre><code class="language-plaintext">X-Forwarded-For: &lt;original client&gt;, &lt;next hop&gt;, ...
</code></pre>
<p>And <code>trustProxy</code> is the switch that decides which value Fastify reads into <code>req.ip</code>:</p>
<ul>
<li><p><code>trustProxy: false</code> → <code>req.ip</code> is the raw socket address. Whoever physically connected.</p>
</li>
<li><p><code>trustProxy: &lt;n&gt;</code> → <code>req.ip</code> is taken from <code>X-Forwarded-For</code> instead — but only across the proxies you declare trustworthy.</p>
</li>
</ul>
<p>It adds no security by itself. It only tells Fastify <em>which value to believe.</em> Locally, nothing sits in front of the app, so the socket address already <em>is</em> the real client — trust no proxy. That's why <code>TRUST_PROXY</code> is empty in local config. Empty isn't a missing value; it's the right value for an environment with no proxy.</p>
<h3>Why you count proxies instead of naming them</h3>
<p>On a managed host (Railway, Render, Fly) there's no single balancer IP to point at. The balancer is a <em>fleet</em> — a pool of edge proxies that scale up and down and rotate addresses you're never handed. "Set <code>trustProxy</code> to the balancer's address" is a trap: pin one IP and it works until the platform adds a node next Tuesday.</p>
<p>The way out is to stop naming addresses and describe <em>trust</em> by position. Two pieces of "who sent this" arrive together, and they disagree:</p>
<p>The <strong>socket address</strong> — the IP at the far end of the real TCP connection. Nobody can forge it (you can't finish a handshake while pretending to hold an IP you don't), but behind a balancer it's <em>the balancer's</em> address: honest, identical on every request, useless for telling clients apart.</p>
<p>The <code>X-Forwarded-For</code> <strong>header</strong> — text that names the actual client. The right machine, but only as trustworthy as whoever wrote it.</p>
<p><code>trustProxy</code>'s real job is to turn the header (right machine, maybe lying) into <code>req.ip</code> safely, using the socket address (honest, but the balancer) as its anchor. The rule every proxy obeys makes that possible:</p>
<blockquote>
<p>Forwarding a request, a proxy appends the address it received the request <em>from</em> — the socket address it saw — onto the end of <code>X-Forwarded-For</code>.</p>
</blockquote>
<p>Watch it fill in. One client, one balancer:</p>
<pre><code class="language-plaintext">client 9.9.9.9  ──TCP──&gt;  balancer  ──TCP──&gt;  your app

at the balancer:  its socket peer is 9.9.9.9    →  appends   X-Forwarded-For: 9.9.9.9
at your app:      header reads   X-Forwarded-For: 9.9.9.9
</code></pre>
<p>The balancer could not put the client into the <em>source IP</em> of its own connection to you — that slot is forced to be the balancer's address. So it wrote the client where it could: an appended header entry.</p>
<p>Fastify reassembles the route nearest-first — socket address, then header entries read right-to-left, because the <em>rightmost</em> was appended by the proxy <em>closest</em> to you:</p>
<pre><code class="language-plaintext">[ balancer,     9.9.9.9 ]
  index 0        index 1
  nearest you    the client
</code></pre>
<p><code>trustProxy: &lt;n&gt;</code> says: "the <code>n</code> hops nearest me are my proxies; trust their appends. <code>req.ip</code> is the entry just past them." For this app there's exactly one proxy between the internet and the app — the load balancer — so <code>n = 1</code>. (The two app instances are <em>not</em> hops: the balancer routes each request to one of them, and that instance sees exactly one proxy in front of it. Two instances is horizontal scale, not a chain.) Add a CDN (Content Delivery Network) in front and it'd be 2. Every forwarding layer you stack is <code>+1</code>. Reason it from your architecture, then confirm it once by logging <code>req.socket.remoteAddress</code> and <code>req.headers['x-forwarded-for']</code> from a device whose public IP you know — your IP's index <em>is</em> <code>n</code>.</p>
<h3>Why the count can't be tricked</h3>
<p>The attacker <em>is</em> the client and wants a fresh IP every request, so they put a lie in the header they send:</p>
<pre><code class="language-plaintext">attacker, real IP 9.9.9.9, sends:   X-Forwarded-For: 1.2.3.4   (a lie)
</code></pre>
<p>But the balancer obeys the rule — it appends <em>what it saw</em>, the attacker's real socket peer <code>9.9.9.9</code>, to the right of the lie:</p>
<pre><code class="language-plaintext">Fastify's list, nearest-first:
[ balancer,     9.9.9.9,   1.2.3.4 ]
  index 0        index 1     index 2
  yours          real IP     the lie
</code></pre>
<p><code>trustProxy: 1</code> stops at index 1 = <strong>9.9.9.9</strong>, the attacker's real address. The forged <code>1.2.3.4</code> sits at index 2 — past the trust boundary, never read.</p>
<p>That's the crux. A proxy you trust always writes the truth to the <em>right</em> of whatever the client wrote. You count in from the right, so the walk crosses only truthful appends and halts before it reaches the client's free text. The attacker can scribble anything on the left; you never read the left.</p>
<p>Both failure modes are the same mistake at opposite extremes — <em>where you stop counting</em>. Stop at zero (<code>false</code>, behind a balancer) and every request collapses onto the balancer's single IP: the whole internet throttled together. Walk to the end (<code>true</code>) and you land on attacker-controlled text: no throttle at all, a limiter that's present, configured-looking, and doing nothing. The safe stop is the exact number of proxies you own. For this app, <code>1</code>.</p>
<h3>A gap still open in the wiring</h3>
<p>Honesty: the portable form — counting — does <strong>not</strong> actually work in this code yet.</p>
<p>Fastify branches on the <em>type</em> of the value: a <strong>number</strong> is a hop count, a <strong>string</strong> is a list of subnets. But the <code>TRUST_PROXY</code> env var is typed as <code>z.string()</code> and passed through untouched, so:</p>
<ul>
<li><p><code>TRUST_PROXY=10.0.0.0/8</code> → string → read as a subnet → the range form works.</p>
</li>
<li><p><code>TRUST_PROXY=1</code> → the string <code>"1"</code> → Fastify tries to read <code>"1"</code> as a <em>subnet</em>, not a hop count → the count silently breaks.</p>
</li>
</ul>
<p>So today the range form works and the count form does not. Closing it is a one-line coercion — if <code>TRUST_PROXY</code> is all digits, pass <code>Number(...)</code> so it reaches the number branch — left as a follow-up.</p>
<h3>The gotcha: a custom error handler eats the 429</h3>
<p>The rate-limit plugin doesn't <code>reply.send</code> the 429 — it <strong>throws</strong> it. A thrown value lands in the app's error handler, and ours only recognizes the app's own <code>AppError</code> type; everything else it treats as an unexpected bug and turns into a <code>500</code>. So the first version, which returned a plain object, made every rate-limited request come back <code>500</code> instead of <code>429</code>. The limiter was working — the <code>x-ratelimit-remaining</code> header counted down to 0 — but the response was wrong.</p>
<p>The fix is to throw something the handler already understands:</p>
<pre><code class="language-ts">errorResponseBuilder: (_req, context) =&gt;
  new AppError('rate_limited', `Too many requests. Try again in ${seconds}s.`, 429)
</code></pre>
<p>The lesson generalizes: a custom error handler owns <em>every</em> error in the app, including the ones plugins throw.</p>
<h3>When this is the wrong shape</h3>
<p><strong>Fail-open on a Redis outage.</strong> If Redis is unreachable, the limiter lets the request through instead of blocking it. That's a deliberate trade: rate limiting is defense-in-depth, not the front door, so a Redis blip shouldn't lock every user out of login. The cost is no throttling at all during a Redis outage. Availability over security, chosen on purpose.</p>
<p><strong>Per-IP doesn't stop a botnet on one account.</strong> This limits requests <em>per source IP</em>. An attacker spread across a thousand IPs, each making a few attempts at one victim's account, stays under every per-IP limit. Defending a single targeted account needs a <em>per-account</em> limit — attempts per email, regardless of source — a different counter for a different threat. Credential stuffing (many accounts, one source) is what per-IP stops; the per-account layer is future work.</p>
<p>Count the attempts, not the passwords. Share the count, so scaling out doesn't dissolve it. Fail open, so the guard never becomes the outage.</p>
<hr />
<h2>Forgot password: a link that proves the inbox</h2>
<p>A password reset looks like a new feature. It's really one we already built, pointed in a new direction.</p>
<p>Email verification proved a claim: <em>you control this inbox.</em> It did that with a single-use, hashed, expiring token mailed as a link. Forgot-password uses the <strong>exact same mechanism</strong> — and then spends that proof differently. Verification flips an <code>email_verified</code> flag. Reset lets you <strong>replace the password.</strong> Same proof of inbox control; a much bigger payoff. That's the whole idea, and also the whole danger: a reset link is a bearer credential that can take over an account, so every property below exists to keep it narrow.</p>
<h3>Two steps, two endpoints</h3>
<pre><code class="language-plaintext">/forgot-password (email)
   → POST /auth/forgot-password   → always 200; IF a password account exists, issue token + email link
   → link: ${APP_URL}/reset-password?token=…   (the FRONTEND, not the API — it has a form to show)
/reset-password (new password)
   → POST /auth/reset-password    → consume token, set hash, revoke ALL sessions
   → /login?reset=1
</code></pre>
<p>One asymmetry worth noticing: the verification link hits <code>GET /auth/verify</code> <strong>on the API</strong> directly, because it has nothing to collect — click and you're done. The reset link points at a <strong>frontend page</strong>, because the user still has to type the new password. The token rides the URL to the browser; the browser POSTs it back with the password.</p>
<h3>No enumeration — the same stance, a third time</h3>
<p><code>/forgot-password</code> returns a byte-identical <code>200 { message: "If that email has an account, we sent a password reset link." }</code> whether or not that email exists. Same stance as login and signup: the endpoint must not become an oracle that confirms which emails are registered. The service only <em>acts</em> — issues a token, sends mail — when a password account exists; otherwise it silently does nothing. The signal lands in an inbox only the owner reads.</p>
<p>The honest caveat: the real path does an extra token-insert + enqueue (~a few ms) that the unknown path skips, so there's a residual <em>timing</em> difference. It's far weaker than login's ~50ms argon2 gap (which we equalize with a decoy hash), and faking a row-insert to hide it isn't worth it at this tier — so it's accepted and documented, not hidden.</p>
<h3>The token: same shape, tighter dials</h3>
<p>Reuses the same <code>generateToken</code> (256 bits) + <code>hashToken</code> (sha256) — only the <strong>hash</strong> is stored, so a DB leak yields no usable link. Single-use: consumed on success, and a used token reads identically to one that never existed. Two dials turned tighter than verification, because a reset is higher-risk:</p>
<ul>
<li><p><strong>1 hour TTL</strong>, not 24. A reset link is more dangerous to leave lying around.</p>
</li>
<li><p><strong>Issuing a new link invalidates the old one</strong> — creating a reset token deletes any prior token for that user first, so only the most recent link works. Request three, only the third resolves.</p>
</li>
</ul>
<h3>The reset is one transaction — all of it, or none</h3>
<p>Verification flipped one flag and deleted one token in a transaction. Reset has <em>more</em> moving parts, and they must move together:</p>
<pre><code class="language-typescript">await db.transaction(async (tx) =&gt; {
  await tx.update(accounts).set({ passwordHash }).where(/* this user's password account */)
  await tx.update(users).set({ emailVerified: true }).where(/* this user */)
  await tx.delete(passwordResetTokens).where(/* this token */)
  await tx.delete(sessions).where(/* every session for this user */)
})
</code></pre>
<p>Why atomic: a crash between "set new hash" and "consume token" would leave a <strong>reusable link</strong> for an already-changed password; a crash the other way would <strong>spend the token without changing the password</strong> — locking the user out with no working link. One transaction makes it all-or-nothing. (The argon2 hash is computed <em>before</em> the transaction, so the slow part doesn't hold row locks; the token is still unconsumed at that point, so a crash there just leaves a valid unused link — the safe failure.)</p>
<p>Two of those four statements deserve their own note:</p>
<ul>
<li><p><code>emailVerified: true</code> — clicking the reset link <em>is</em> proof of inbox control, the same proof verification asks for. So a reset doubles as a verification; no reason to leave the email unverified afterward.</p>
</li>
<li><p><strong>delete every session</strong> — a reset is the "I think I'm compromised" button. Revoking all sessions logs out anyone holding a stolen cookie. The caveat is the one from the session cache: it has a 60-second read-through TTL, so a cached session can outlive the reset by up to a minute — we delete the Postgres rows instantly but don't hunt down per-user cache keys. Accepted, and called out rather than discovered later.</p>
</li>
</ul>
<h3>One more email: the alarm</h3>
<p>After a successful reset we queue a <em>"your password was changed"</em> notice to the owner, riding the same email queue. If an attacker who breached the inbox resets the password, this is the message that tells the real owner something happened. It's cheap, and it's the difference between a silent takeover and a noticed one.</p>
<h3>Scope, and when this is the wrong shape</h3>
<p>Forgot-password is <strong>password accounts only.</strong> A Google-only account has no password to reset, so the flow deliberately does nothing for it (and, thanks to the uniform response, doesn't reveal that). Letting this flow <em>set</em> a first password on a Google account is a real feature — the "set-password / reverse link" door — but it's a different threat model, kept separate on purpose.</p>
<p>Two limits carry over from earlier. The <strong>commit-and-enqueue gap</strong> is here too: the token row is written, then the email enqueued best-effort, so a dropped enqueue means "check your email" with no mail — tolerable precisely because retrying is free. And rate limiting is <strong>per-IP, not per-account</strong>: a distributed attacker could trigger many reset emails to one victim from many IPs. The token is harmless without the inbox, so this is annoyance, not compromise; a per-account send throttle is the next dial if it matters.</p>
<p>Verification asked the inbox to prove a claim. Reset asks the same proof — and then lets it rewrite the password, once, atomically, and tells you it happened.</p>
<hr />
<h2>What to remember after all this</h2>
<p>Trace Mara end to end and the same handful of ideas keep doing the work.</p>
<p><strong>A claim is not a proof.</strong> A typed-in email is a claim; a clicked verification link is a proof. Almost every dangerous decision in auth comes down to refusing to treat one as the other — the Google takeover, email verification, password reset all turn on it.</p>
<p><strong>The interesting question is never "can it be stolen."</strong> Cookies, JWTs, reset links — assume they all leak. The design question is what the thief can do next, and whether you can turn the stolen thing off. That single question is why redline runs server sessions instead of JWTs.</p>
<p><strong>The best defenses are holes you never built.</strong> Session fixation can't happen because there's no pre-login id to reuse. The forged-callback attack can't happen because <code>state</code> ties start to finish. You don't add those defenses; you build a shape that has no room for the attack.</p>
<p><strong>One secret, stored as its fingerprint, every time.</strong> Passwords, session tokens, verification tokens, reset tokens — the raw secret lives in exactly one place the user holds, and the database keeps only a hash. A full database leak hands an attacker a column of fingerprints that open nothing.</p>
<p><strong>Say the same thing to everyone; tell the truth only to the inbox.</strong> Login, signup, and forgot-password all give one answer regardless of whether you exist. The real signal moves to the one channel only the owner can read.</p>
<p>And the whole machine, in three beats:</p>
<pre><code class="language-plaintext">The password proves who you are, once, and is never stored.
The session proves you're still you on every request, and is stored only as a hash.
Every link we mail proves you own the inbox — and that one proof is what verifies an address, links a second login, and resets a forgotten password.
</code></pre>
]]></content:encoded></item><item><title><![CDATA[RAG From First Principles: How a PDF Becomes a Searchable Answer]]></title><description><![CDATA[As a disclaimer, my project was created without spending any money on tokens at Voyage, Grok or Clerk for auth - meaning all the implementation was done on their free tier. The principles remain the s]]></description><link>https://featuringcode.com/using-rag-to-search-pdfs</link><guid isPermaLink="true">https://featuringcode.com/using-rag-to-search-pdfs</guid><category><![CDATA[RAG ]]></category><category><![CDATA[pdf]]></category><category><![CDATA[pgvector]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 20 Jun 2026 10:06:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6113a30e4ee7b05e4ceefba5/5f8419a0-f8dc-422f-8d2d-65d829386a0d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>As a disclaimer, my project was created without spending any money on tokens at <a href="https://www.voyageai.com/">Voyage</a>, <a href="https://console.groq.com/keys">Grok</a> or <a href="https://dashboard.clerk.com/">Clerk for auth</a> - meaning all the implementation was done on their free tier. The principles remain the same no matter what models you use. Find the project <a href="https://github.com/mmswi/pdf-rag">here</a>.</p>
</blockquote>
<p>I wanted to understand RAG because it is one of those things that sounds simple until you look at the words people use when explaining it.</p>
<p>Embeddings.</p>
<p>Vectors.</p>
<p>Cosine distance.</p>
<p>Vector databases.</p>
<p>Rerankers.</p>
<p>HNSW.</p>
<p>And usually the explanation goes something like this:</p>
<pre><code class="language-txt">Split the document into chunks,
embed them,
store them in a vector database,
then retrieve the relevant context.
</code></pre>
<p>Which is not really an explanation.</p>
<p>It is just a list of things you are now supposed to understand.</p>
<p>So this is how I think about RAG now, after building a PDF chat application.</p>
<ol>
<li><p>The user uploads a PDF.</p>
</li>
<li><p>The application reads the PDF locally.</p>
</li>
<li><p>The application splits the PDF into smaller pieces locally.</p>
</li>
<li><p>The application sends those smaller pieces to an embeddings API.</p>
</li>
<li><p>The embeddings API returns numbers.</p>
</li>
<li><p>Those numbers are saved in PostgreSQL next to the original text.</p>
</li>
<li><p>Later, when the user asks a question, we turn that question into numbers too.</p>
</li>
<li><p>Then PostgreSQL finds the stored chunks whose numbers are closest to the question numbers.</p>
</li>
<li><p>Those chunks are sent to the LLM.</p>
</li>
<li><p>The LLM writes the answer and we stream it back to the user.</p>
</li>
</ol>
<p>That is RAG.</p>
<hr />
<h1>The whole thing in one picture</h1>
<p>Before explaining every part, this is the full mental model:</p>
<pre><code class="language-txt">PDF file
↓
Read PDF bytes locally in Node.js
↓
Extract text locally with unpdf
↓
Split text locally into chunks
↓
Send chunks to Voyage AI
↓
Voyage returns one vector per chunk
↓
Save chunk text + vector in PostgreSQL
↓
User asks a question
↓
Send only the question to Voyage AI
↓
Voyage returns a fresh question vector
↓
pgvector compares that vector with stored chunk vectors
↓
PostgreSQL returns the closest chunks
↓
Voyage reranks the possible chunks
↓
Send the best chunks to the LLM
↓
LLM writes an answer using those chunks
</code></pre>
<p>There are three important places where work happens:</p>
<pre><code class="language-txt">Locally in our worker:
Read PDFs
Extract text
Split text into chunks
Save data in PostgreSQL

Externally in Voyage AI:
Create document embeddings
Create question embeddings
Rerank retrieved chunks

Inside PostgreSQL with pgvector:
Store vectors
Compare vectors
Find similar chunks quickly
</code></pre>
<p>This distinction helped me a lot.</p>
<p>Chunking is <strong>not</strong> done by Voyage.</p>
<p>PDF parsing is <strong>not</strong> done by Voyage.</p>
<p>PostgreSQL does <strong>not</strong> create embeddings.</p>
<p>Voyage does <strong>not</strong> store our PDFs.</p>
<p>Each part has <strong>one</strong> job.</p>
<hr />
<h1>What problem does RAG solve?</h1>
<p>Imagine the user uploads a 100-page car manual and asks:</p>
<pre><code class="language-txt">How long is the warranty?
</code></pre>
<p>The LLM does not automatically know what is inside that specific PDF.</p>
<p>You could send the entire PDF to the LLM every time the user asks a question.</p>
<p>For a small document, this can be a good idea.</p>
<p>But for large documents, or many documents, it becomes a problem.</p>
<pre><code class="language-txt">Large prompt
↓
More tokens
↓
More cost
↓
Slower answer
↓
More irrelevant information
↓
Harder citations
</code></pre>
<p>The user does not need the entire manual to answer a warranty question.</p>
<p>The user probably needs two or three paragraphs.</p>
<p>So RAG does this:</p>
<pre><code class="language-txt">User asks:
"How long is the warranty?"

↓

Find the warranty paragraphs

↓

Give those paragraphs to the LLM

↓

Ask the LLM to answer only from those paragraphs
</code></pre>
<p>RAG means Retrieval-Augmented Generation.</p>
<pre><code class="language-txt">Retrieval
Find useful text.

Augmented
Add that useful text to the prompt.

Generation
Let the LLM write the answer.
</code></pre>
<p>The important thing is that RAG is not a model.</p>
<p>RAG is a pipeline around a model.</p>
<p>The LLM is still the part that writes words.</p>
<p>RAG is the part that finds the information the LLM should use.</p>
<hr />
<h1>First: a PDF is not searchable text yet</h1>
<p>When a user uploads a PDF, we have a file.</p>
<p>At first, it is just bytes.</p>
<pre><code class="language-txt">PDF file
↓
Raw bytes
</code></pre>
<p>In the worker, we read those bytes from storage.</p>
<pre><code class="language-ts">const bytes = await readDocumentFile(document.storagePath);
parsed = await parsePdf(bytes);
</code></pre>
<p>The first line reads the original PDF file from disk.</p>
<pre><code class="language-ts">const bytes = await readDocumentFile(document.storagePath);
</code></pre>
<p><code>bytes</code> means the raw file contents.</p>
<p>Not text yet.</p>
<p>Not paragraphs yet.</p>
<p>Not chunks yet.</p>
<p>Just the actual PDF file data.</p>
<p>Then we parse it.</p>
<pre><code class="language-ts">parsed = await parsePdf(bytes);
</code></pre>
<p>In my project, <code>parsePdf</code> uses <code>unpdf</code>.</p>
<p><code>unpdf</code> reads the text layer inside the PDF and gives us text page by page.</p>
<p>Conceptually, the result looks like this:</p>
<pre><code class="language-ts">{
  pages: [
    {
      pageNumber: 1,
      text: "Welcome to the product manual..."
    },
    {
      pageNumber: 2,
      text: "Warranty coverage starts on the date..."
    }
  ]
}
</code></pre>
<p>This is an important point.</p>
<p>The PDF parser does not know anything about RAG.</p>
<p>It does not know embeddings.</p>
<p>It does not know vectors.</p>
<p>It just answers this question:</p>
<pre><code class="language-txt">What text exists on every page of this PDF?
</code></pre>
<hr />
<h1>Why page numbers matter</h1>
<p>Keeping page numbers looks like a small detail.</p>
<p>It is not.</p>
<p>Imagine the application gives this answer:</p>
<pre><code class="language-txt">The warranty lasts for two years.
</code></pre>
<p>The user should be able to see where that answer came from.</p>
<pre><code class="language-txt">Source: Car manual.pdf, page 14
</code></pre>
<p>The page number starts at parsing time.</p>
<pre><code class="language-txt">PDF page
↓
Parsed page
↓
Chunk created from that page
↓
Chunk saved with page number
↓
Citation shown to user
</code></pre>
<p>If you lose this information early, citations become much harder later.</p>
<p>That is why the parser returns text per page instead of one giant string.</p>
<hr />
<h1>Not every PDF has readable text</h1>
<p>Some PDFs look normal when you open them.</p>
<p>But they are actually just images.</p>
<p>For example, someone prints a contract, signs it, scans it and uploads the scanned version.</p>
<p>You can see words on the page.</p>
<p>But internally the PDF may contain this:</p>
<pre><code class="language-txt">One large image
</code></pre>
<p>instead of this:</p>
<pre><code class="language-txt">Selectable text
</code></pre>
<p><code>unpdf</code> can read actual PDF text.</p>
<p>It does not perform OCR.</p>
<p>OCR means Optical Character Recognition.</p>
<p>OCR is the process that reads text from an image.</p>
<p>So, in the current version of the app:</p>
<pre><code class="language-txt">Text PDF
✓ We can extract text
✓ We can chunk it
✓ We can create embeddings

Scanned image-only PDF
✗ No text to extract
✗ No chunks to create
✗ Document fails with "no OCR"
</code></pre>
<p>This is not a temporary error.</p>
<p>Retrying will not help.</p>
<p>The PDF needs OCR support, or the user needs to upload a text-based PDF.</p>
<hr />
<h1>The first important RAG concept: chunks</h1>
<p>A chunk is a small piece of a document.</p>
<p>That is all it is.</p>
<p>A PDF might have 100 pages.</p>
<p>You do not want to treat all 100 pages as one big thing.</p>
<p>Imagine a contract like this:</p>
<pre><code class="language-txt">Page 1
Definitions

Page 2
Payment terms

Page 3
Late payment rules

Page 4
Termination rules

Page 5
Confidentiality
</code></pre>
<p>The user asks:</p>
<pre><code class="language-txt">What happens if a payment is late?
</code></pre>
<p>The answer is probably in the late payment section.</p>
<p>It is not in the definition section.</p>
<p>It is not in the confidentiality section.</p>
<p>So we split the document into smaller pieces.</p>
<pre><code class="language-txt">Chunk 1
Definitions

Chunk 2
Payment terms

Chunk 3
Late payment rules

Chunk 4
Termination rules

Chunk 5
Confidentiality
</code></pre>
<p>Now the application can retrieve chunk 3 instead of sending the entire contract to the LLM.</p>
<p>A chunk is not necessarily one paragraph.</p>
<p>It can contain several paragraphs.</p>
<p>It can contain a heading and paragraphs below it.</p>
<p>It can contain part of a longer section.</p>
<p>The goal is to make each piece small enough to be specific, but large enough to still make sense.</p>
<hr />
<h1>Chunking happens locally</h1>
<p>This is important.</p>
<p>Chunking is not an AI call.</p>
<p>Chunking is not done by Voyage.</p>
<p>Chunking happens locally in the worker after parsing the PDF.</p>
<pre><code class="language-txt">PDF pages
↓
Plain text
↓
Local JavaScript code
↓
Chunks
</code></pre>
<p>In my project, the chunking function is pure.</p>
<pre><code class="language-ts">const chunks = chunkDocument(parsed);
</code></pre>
<p>Pure means:</p>
<pre><code class="language-txt">It receives data.

It returns data.

It does not call an API.

It does not write to the database.

It does not modify something outside itself.
</code></pre>
<p>This makes it easier to test.</p>
<p>You can give it a parsed document and check the chunks it returns.</p>
<hr />
<h1>How the chunker decides where to split</h1>
<p>You do not want to split text randomly.</p>
<p>This is bad:</p>
<pre><code class="language-txt">Chunk 1
The warranty covers defects in materials and

Chunk 2
workmanship for a period of two years.
</code></pre>
<p>Neither chunk is useful by itself.</p>
<p>So the chunker tries to split at useful boundaries.</p>
<pre><code class="language-txt">Paragraph boundary
↓
Line boundary
↓
Sentence boundary
↓
Word boundary
↓
Hard character limit
</code></pre>
<p>The hard character limit is the last option.</p>
<p>It only happens when there is no better place to split.</p>
<p>In the current project, a chunk can be around:</p>
<pre><code class="language-txt">4000 characters
</code></pre>
<p>This is roughly around 1000 tokens.</p>
<p>A token is not exactly a word.</p>
<p>But a rough approximation for English is:</p>
<pre><code class="language-txt">4 characters ≈ 1 token
</code></pre>
<p>This is enough for chunking decisions.</p>
<p>The chunker does not need to know the exact token count used by every LLM.</p>
<p>It only needs a reasonable estimate.</p>
<hr />
<h1>Why chunks overlap</h1>
<p>This is where things get more interesting.</p>
<p>A useful idea can start at the end of one chunk and finish at the beginning of the next one.</p>
<p>Imagine this document text:</p>
<pre><code class="language-txt">The customer must pay every invoice within 30 days.

If payment remains unpaid for more than 30 days after its due date,
the supplier may terminate this agreement.

Termination does not remove the customer's obligation to pay all
amounts already due.
</code></pre>
<p>Without overlap, the chunks could look like this:</p>
<pre><code class="language-txt">Chunk 10

The customer must pay every invoice within 30 days.

If payment remains unpaid for more than 30 days after its due date,
the supplier may terminate this agreement.
</code></pre>
<pre><code class="language-txt">Chunk 11

Termination does not remove the customer's obligation to pay all
amounts already due.
</code></pre>
<p>Chunk 11 is missing context.</p>
<p>It says:</p>
<pre><code class="language-txt">Termination does not remove...
</code></pre>
<p>But termination of what?</p>
<p>Why did termination happen?</p>
<p>If chunk 11 gets retrieved on its own, it is weaker.</p>
<p>With overlap, part of chunk 10 is repeated inside chunk 11.</p>
<pre><code class="language-txt">Chunk 10

The customer must pay every invoice within 30 days.

If payment remains unpaid for more than 30 days after its due date,
the supplier may terminate this agreement.
</code></pre>
<pre><code class="language-txt">Chunk 11

If payment remains unpaid for more than 30 days after its due date,
the supplier may terminate this agreement.

Termination does not remove the customer's obligation to pay all
amounts already due.
</code></pre>
<p>Now the repeated text is the overlap.</p>
<p>Chunk 11 can stand on its own.</p>
<p>It knows what type of termination it is talking about.</p>
<p>In my project, the target overlap is:</p>
<pre><code class="language-txt">600 characters
</code></pre>
<p>The exact repeated text is not always exactly 600 characters because the chunker tries to keep safe boundaries like paragraphs and sentences.</p>
<p>The mental model is:</p>
<pre><code class="language-txt">Chunk overlap is repeated context.

It slightly repeats text.

It prevents ideas from being cut in half.
</code></pre>
<hr />
<h1>Chunk size is a tradeoff</h1>
<p>You might think larger chunks are always better.</p>
<p>They contain more context.</p>
<p>But they can also contain too many unrelated topics.</p>
<p>Imagine this chunk:</p>
<pre><code class="language-txt">Introduction
Payment terms
Warranty
Troubleshooting
Technical specifications
</code></pre>
<p>The user asks about warranty.</p>
<p>The chunk contains the warranty answer, but it also contains four unrelated subjects.</p>
<p>That makes retrieval less precise.</p>
<p>Very small chunks are also bad.</p>
<pre><code class="language-txt">Chunk 1
The warranty starts

Chunk 2
on the date of purchase

Chunk 3
and lasts for two years
</code></pre>
<p>These chunks are too small to be useful.</p>
<p>So chunking is a balance:</p>
<pre><code class="language-txt">Very large chunks
More context
Less precise retrieval

Very small chunks
More precise retrieval
Less context

Medium chunks with overlap
Usually a useful compromise
</code></pre>
<p>There is no perfect chunk size that works for every document.</p>
<p>This is why a serious RAG app evaluates chunking changes instead of changing numbers based on vibes.</p>
<hr />
<h1>Page-aware chunks</h1>
<p>My chunks stay inside one PDF page.</p>
<p>This means a chunk does not start on page 7 and end on page 8.</p>
<p>Why?</p>
<p>Because citations become simple.</p>
<pre><code class="language-txt">Chunk 14
Contract.pdf
Page 7
</code></pre>
<p>Instead of:</p>
<pre><code class="language-txt">Chunk 14
Contract.pdf
Pages 7–8
Maybe page 7
Maybe page 8
</code></pre>
<p>Page-aware chunking is not always the only valid approach.</p>
<p>But for document citations, it is a very practical one.</p>
<p>The chunk stores:</p>
<pre><code class="language-txt">document_id
owner_id
page_number
chunk_index
token_count
</code></pre>
<p>The <code>chunk_index</code> tells us the reading order.</p>
<p>The page number tells us where the user can find the original text.</p>
<hr />
<h1>After chunking, we still only have text</h1>
<p>At this point, we have chunks like this:</p>
<pre><code class="language-txt">Chunk 1
"The warranty covers defects in materials and workmanship..."

Chunk 2
"The warranty does not cover accidental damage..."

Chunk 3
"To reset the device, hold the power button for five seconds..."
</code></pre>
<p>These are useful for humans.</p>
<p>But PostgreSQL cannot magically understand that this question:</p>
<pre><code class="language-txt">How long am I protected if the product breaks?
</code></pre>
<p>is related to this text:</p>
<pre><code class="language-txt">The manufacturer guarantees the product against defects for 24 months.
</code></pre>
<p>The words are different.</p>
<pre><code class="language-txt">Question:
protected
breaks

Document:
guarantees
defects
24 months
</code></pre>
<p>But the meaning is similar.</p>
<p>This is what embeddings solve.</p>
<hr />
<h1>The second important RAG concept: embeddings</h1>
<p>An embedding is a way to turn text into numbers.</p>
<p>For example, this text:</p>
<pre><code class="language-txt">The warranty lasts for two years.
</code></pre>
<p>becomes something like this:</p>
<pre><code class="language-txt">[0.18, -0.42, 0.91, 0.03, ...]
</code></pre>
<p>This list of numbers is called a vector.</p>
<p>In my project, every embedding contains:</p>
<pre><code class="language-txt">1024 numbers
</code></pre>
<p>So every chunk becomes a point in 1024-dimensional space.</p>
<p>You cannot really visualize 1024 dimensions.</p>
<p>But you do not need to.</p>
<p>The important idea is:</p>
<pre><code class="language-txt">Texts with similar meanings should end up near each other.

Texts with different meanings should end up farther apart.
</code></pre>
<p>For example:</p>
<pre><code class="language-txt">How long is the warranty?
</code></pre>
<p>and:</p>
<pre><code class="language-txt">The product is covered for two years from the date of purchase.
</code></pre>
<p>should be close together.</p>
<p>But this:</p>
<pre><code class="language-txt">Hold the power button for five seconds to restart the device.
</code></pre>
<p>should be farther away.</p>
<hr />
<h1>Embeddings happen externally in Voyage AI</h1>
<p>This is another important separation.</p>
<p>The worker creates chunks locally.</p>
<p>Then it sends those chunks to Voyage AI.</p>
<pre><code class="language-txt">Local worker
↓
Chunk text

↓

Voyage AI
↓
Embedding vectors
</code></pre>
<p>The code conceptually looks like this:</p>
<pre><code class="language-ts">const embeddings = await voyageEmbedTextsIntoVectors({
  texts,
  inputType: "document"
});
</code></pre>
<p>The worker sends text.</p>
<p>Voyage returns numbers.</p>
<pre><code class="language-txt">Chunk text
↓
Voyage embedding model
↓
1024-number vector
</code></pre>
<p>Voyage does not receive the original PDF structure.</p>
<p>It does not know about pages.</p>
<p>It does not know about users.</p>
<p>It just receives text chunks and returns vectors.</p>
<p>That is why we keep page numbers, ownership and document metadata in our own database.</p>
<hr />
<h1>An embedding model is not the chat model</h1>
<p>This can be confusing at first.</p>
<p>An embedding model does not write answers.</p>
<p>It does not chat.</p>
<p>It does not summarize.</p>
<p>It does one job:</p>
<pre><code class="language-txt">Text
↓
Numbers that represent meaning
</code></pre>
<p>The embedding model is useful for search.</p>
<p>The LLM is useful for generating an answer.</p>
<pre><code class="language-txt">Embedding model:
"Which document pieces seem related to this question?"

LLM:
"Using these document pieces, how should I explain the answer?"
</code></pre>
<p>Those are different jobs.</p>
<hr />
<h1>What gets stored in the database?</h1>
<p>This was one of the questions I had while building this.</p>
<p>Do we save the chunks?</p>
<p>Do we save the vectors too?</p>
<p>Yes.</p>
<p>Both are saved in the same table.</p>
<p>The table is called:</p>
<pre><code class="language-txt">document_chunks
</code></pre>
<p>Every row represents one chunk of one document.</p>
<p>Conceptually, it looks like this:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>What it contains</th>
</tr>
</thead>
<tbody><tr>
<td><code>content</code></td>
<td>The original readable chunk text</td>
</tr>
<tr>
<td><code>embedding</code></td>
<td>The 1024-number vector from Voyage</td>
</tr>
<tr>
<td><code>content_tsv</code></td>
<td>A PostgreSQL full-text version of the chunk text</td>
</tr>
<tr>
<td><code>document_id</code></td>
<td>The parent PDF</td>
</tr>
<tr>
<td><code>owner_id</code></td>
<td>The user who owns the PDF</td>
</tr>
<tr>
<td><code>page_number</code></td>
<td>The PDF page for citations</td>
</tr>
<tr>
<td><code>chunk_index</code></td>
<td>The chunk’s reading order</td>
</tr>
<tr>
<td><code>token_count</code></td>
<td>Rough size information</td>
</tr>
</tbody></table>
<p>One row might look like this:</p>
<pre><code class="language-txt">content:
"The warranty covers defects in materials and workmanship
for two years from the purchase date."

embedding:
[0.18, -0.42, 0.91, 0.03, ...]

page_number:
14

chunk_index:
31
</code></pre>
<p>The raw text and the vector belong together.</p>
<pre><code class="language-txt">Raw text
Used later as context for the LLM.

Vector
Used to find that raw text.
</code></pre>
<p>The vector is not useful to show to the user.</p>
<p>The raw text is not enough for semantic similarity search.</p>
<p>We need both.</p>
<hr />
<h1>Why not use a separate vector database?</h1>
<p>You often see tutorials using:</p>
<pre><code class="language-txt">PostgreSQL for normal data

Pinecone or Weaviate for vectors
</code></pre>
<p>You can do that.</p>
<p>But you do not have to.</p>
<p>I use PostgreSQL with pgvector.</p>
<p>pgvector is a PostgreSQL extension.</p>
<p>It adds:</p>
<pre><code class="language-txt">A vector column type

Vector similarity operators

Vector indexes
</code></pre>
<p>So the same PostgreSQL database can store:</p>
<pre><code class="language-txt">Documents

Chat messages

Chunks

Embeddings

Citations
</code></pre>
<p>There is no separate vector database to keep in sync.</p>
<p>The vectors live next to the data they describe.</p>
<pre><code class="language-txt">PostgreSQL
├── documents
├── document_chunks
│   ├── content
│   ├── embedding
│   ├── page_number
│   └── owner_id
└── chat_messages
</code></pre>
<p>This makes the architecture simpler.</p>
<p>When deleting a document, the related chunks can be deleted too.</p>
<p>When searching, we can filter by owner and document in the same query.</p>
<hr />
<h1>The three representations of one chunk</h1>
<p>One chunk is stored in three useful forms.</p>
<pre><code class="language-txt">content
The real text.

embedding
The meaning as numbers.

content_tsv
A full-text search representation.
</code></pre>
<p>Each one solves a different problem.</p>
<pre><code class="language-txt">content
Used as context for the LLM.
Shown in the debug panel.
Used for citations.

embedding
Used for semantic search.
Finds similar meaning.

content_tsv
Used for exact text search.
Useful for codes, IDs and literal words.
</code></pre>
<p>The <code>content_tsv</code> column is generated by PostgreSQL from <code>content</code>.</p>
<p>The application does not manually write it.</p>
<p>You store the text, and PostgreSQL builds the searchable text representation.</p>
<p>That is useful because exact identifiers do not always work well with embeddings.</p>
<p>For example:</p>
<pre><code class="language-txt">INV-2025-0492

SKU-88771

A7F9-KL22
</code></pre>
<p>These are not really meanings.</p>
<p>They are exact codes.</p>
<p>For those, literal search is better.</p>
<hr />
<h1>Now the PDF is searchable</h1>
<p>At upload time, the worker does this:</p>
<pre><code class="language-txt">Read PDF
↓
Parse PDF text
↓
Split text into chunks
↓
Send chunks to Voyage
↓
Receive vectors
↓
Store text + vectors in PostgreSQL
</code></pre>
<p>This is done once per uploaded document.</p>
<p>The chunk vectors are saved.</p>
<p>They do not need to be created again every time the user asks a question.</p>
<p>That is why RAG can be fast enough.</p>
<p>The expensive work is done when the document is ingested.</p>
<hr />
<h1>What happens when the user asks a question?</h1>
<p>Imagine the user asks:</p>
<pre><code class="language-txt">What happens when an invoice is overdue?
</code></pre>
<p>We do not split the question into chunks.</p>
<p>It is already small.</p>
<p>But we do create a fresh embedding for it.</p>
<pre><code class="language-txt">Question text
↓
Voyage AI
↓
Fresh query vector
</code></pre>
<p>Conceptually:</p>
<pre><code class="language-ts">const queryVector = await voyageEmbedQueryIntoVector(question);
</code></pre>
<p>This vector is usually not saved in the database.</p>
<p>It is temporary.</p>
<p>It exists for this retrieval request.</p>
<pre><code class="language-txt">Stored forever:
Document chunk vectors

Created fresh per question:
Question vector
</code></pre>
<p>The question needs a fresh vector because every question is different.</p>
<pre><code class="language-txt">How long is the warranty?

What does the warranty cover?

Can I transfer the warranty?

Does the warranty cover water damage?
</code></pre>
<p>Every question has a different meaning.</p>
<p>Every question needs its own vector.</p>
<hr />
<h1>Why query embeddings and document embeddings are different</h1>
<p>The application uses Voyage with two input types.</p>
<pre><code class="language-txt">inputType: "document"
</code></pre>
<p>for document chunks.</p>
<p>And:</p>
<pre><code class="language-txt">inputType: "query"
</code></pre>
<p>for user questions.</p>
<p>This is called asymmetric retrieval.</p>
<p>A question is usually short.</p>
<p>A document chunk is usually longer and more formal.</p>
<p>For example:</p>
<pre><code class="language-txt">Question:
What happens when an invoice is overdue?

Document chunk:
If payment remains unpaid for more than 30 days after its due date,
the supplier may terminate this agreement.
</code></pre>
<p>These texts do not look the same.</p>
<p>But they should match.</p>
<p>Using a query embedding mode and a document embedding mode helps the model place these two kinds of text in compatible positions.</p>
<hr />
<h1>How pgvector finds similar chunks</h1>
<p>Now we have:</p>
<pre><code class="language-txt">Question vector
[0.12, -0.40, 0.88, ...]

Stored chunk vector
[0.18, -0.42, 0.91, ...]
</code></pre>
<p>We need to decide whether they are close.</p>
<p>One common way is cosine similarity.</p>
<p>Cosine similarity compares the direction of two vectors.</p>
<p>Imagine vectors as arrows.</p>
<pre><code class="language-txt">Question:
→

Late payment chunk:
→

Device reset chunk:
↑
</code></pre>
<p>The question and late payment chunk point in a similar direction.</p>
<p>The device reset chunk points in a different direction.</p>
<p>So the late payment chunk is more likely to be relevant.</p>
<p>The formula is:</p>
<pre><code class="language-txt">cosineSimilarity(a, b) =
  dotProduct(a, b) / (length(a) × length(b))
</code></pre>
<p>You do not need to calculate this by hand.</p>
<p>The important result is:</p>
<pre><code class="language-txt">High cosine similarity
= vectors point in a similar direction
= text probably has a similar meaning
</code></pre>
<p>PostgreSQL often uses cosine distance instead.</p>
<pre><code class="language-txt">cosineDistance = 1 - cosineSimilarity
</code></pre>
<p>So:</p>
<pre><code class="language-txt">High similarity
= low distance
= good match
</code></pre>
<pre><code class="language-txt">Low similarity
= high distance
= bad match
</code></pre>
<p>In the database query, this looks like:</p>
<pre><code class="language-sql">ORDER BY embedding &lt;=&gt; queryVector
</code></pre>
<p>This means:</p>
<pre><code class="language-txt">Order stored chunk vectors by cosine distance.

Closest vectors first.
</code></pre>
<p>The database returns the chunks whose meaning is closest to the question.</p>
<hr />
<h1>What vector search is actually doing</h1>
<p>Imagine these chunks exist in the database:</p>
<pre><code class="language-txt">Chunk A
The warranty covers defects for two years.

Chunk B
The customer must pay invoices within 30 days.

Chunk C
If payment remains unpaid for more than 30 days,
the supplier may terminate this agreement.

Chunk D
Hold the power button for five seconds to restart the device.
</code></pre>
<p>The user asks:</p>
<pre><code class="language-txt">What happens when an invoice is overdue?
</code></pre>
<p>The query embedding should end up closer to chunks B and C.</p>
<pre><code class="language-txt">Question vector
↓
Compare against stored vectors
↓
Closest chunks

1. Chunk C
2. Chunk B
3. Maybe another payment-related chunk
4. Not chunk D
</code></pre>
<p>The database returns the raw text for these chunks.</p>
<p>The raw text is what the LLM needs later.</p>
<p>The vector is only the way we found it.</p>
<hr />
<h1>What is HNSW?</h1>
<p>At first, you might think PostgreSQL compares the question vector with every chunk vector in the entire database.</p>
<p>It could.</p>
<p>But that gets slow as the number of chunks grows.</p>
<p>Imagine one million chunks.</p>
<pre><code class="language-txt">Question
↓
Compare against 1,000,000 vectors
↓
Slow
</code></pre>
<p>HNSW is an index that helps pgvector find nearby vectors quickly.</p>
<p>HNSW means:</p>
<pre><code class="language-txt">Hierarchical Navigable Small World
</code></pre>
<p>The name sounds scary.</p>
<p>The idea is easier.</p>
<p>Imagine all chunk vectors are connected in a graph.</p>
<p>Similar chunks have links between them.</p>
<p>There are several layers.</p>
<pre><code class="language-txt">Top layer
A ------------------------- F

Middle layer
A ----- C ----- D ----- F

Bottom layer
A - B - C - D - E - F - G - H
</code></pre>
<p>The top layer has fewer, longer links.</p>
<p>The bottom layer has more detailed local links.</p>
<p>A search starts at the top.</p>
<p>It jumps quickly toward vectors that seem closer to the question.</p>
<p>Then it goes down a layer.</p>
<p>Then it refines the search.</p>
<p>Instead of checking every chunk, it checks a much smaller useful neighborhood.</p>
<pre><code class="language-txt">Without HNSW:
Check everything.

With HNSW:
Quickly navigate toward likely nearby chunks.
</code></pre>
<p>HNSW is approximate.</p>
<p>This means it can occasionally miss the mathematically perfect closest vector.</p>
<p>But it is much faster.</p>
<p>For RAG, this tradeoff is usually worth it.</p>
<hr />
<h1>A small multi-user HNSW problem</h1>
<p>There is one interesting detail when users have separate documents.</p>
<p>Imagine the database has chunks from many users.</p>
<p>The vector index first finds globally close chunks.</p>
<p>Then SQL filters by:</p>
<pre><code class="language-txt">owner_id = current user
</code></pre>
<p>That can cause a problem.</p>
<pre><code class="language-txt">1. HNSW finds close chunks from all users.

2. PostgreSQL removes chunks that do not belong to the current user.

3. The current user might have no chunks left.
</code></pre>
<p>This is especially possible when one user has only a small number of documents.</p>
<p>The solution is iterative scanning.</p>
<p>It tells pgvector:</p>
<pre><code class="language-txt">Keep searching for more candidates until enough chunks pass the owner filter.
</code></pre>
<p>The implementation sets this inside the database transaction.</p>
<p>That is a small detail, but it matters for multi-user retrieval quality.</p>
<hr />
<h1>Vector retrieval is fast, but not perfect</h1>
<p>The first vector search gives us possible chunks.</p>
<p>In my project, it gets a pool of 12 chunks.</p>
<pre><code class="language-txt">Question
↓
Question embedding
↓
pgvector finds 12 close chunks
↓
Candidate pool
</code></pre>
<p>These are not necessarily the final best chunks.</p>
<p>They are good possibilities.</p>
<p>This first stage is called recall.</p>
<p>Recall asks:</p>
<pre><code class="language-txt">Did we include the correct answer somewhere in the candidate pool?
</code></pre>
<p>The goal is not perfect ordering yet.</p>
<p>The goal is to avoid missing the answer.</p>
<hr />
<h1>Bi-encoder: the first retrieval stage</h1>
<p>The embedding system is called a bi-encoder.</p>
<p>Bi means two.</p>
<pre><code class="language-txt">One side:
Embed the question.

Other side:
Embed the document chunk.
</code></pre>
<p>The question and the chunk are embedded separately.</p>
<pre><code class="language-txt">Question
↓
Question vector

Chunk
↓
Chunk vector

Question vector + chunk vector
↓
Cosine distance
</code></pre>
<p>The question and chunk do not actually meet inside the embedding model.</p>
<p>They are converted separately into numbers.</p>
<p>Then we compare those numbers.</p>
<p>This is fast because document chunk vectors were already created during upload.</p>
<p>The only new work during chat is:</p>
<pre><code class="language-txt">Create one fresh query vector.
</code></pre>
<p>But this speed comes with a limitation.</p>
<p>Vector search can find chunks with similar meaning.</p>
<p>It can be weaker for exact weird-looking values.</p>
<hr />
<h1>Exact IDs are not concepts</h1>
<p>Imagine the user asks:</p>
<pre><code class="language-txt">What does invoice INV-2025-0492 say?
</code></pre>
<p>The useful part is:</p>
<pre><code class="language-txt">INV-2025-0492
</code></pre>
<p>That is not a concept like warranty or payment.</p>
<p>It is an exact identifier.</p>
<p>You do not want a semantically similar invoice.</p>
<p>You want this exact invoice.</p>
<p>So after vector search, the application checks whether the query contains identifier-shaped text.</p>
<p>For example:</p>
<pre><code class="language-txt">INV-2025-0492

SKU-88771

A7F9-KL22

987654321
</code></pre>
<p>If it does, the app performs an exact keyword search using <code>content_tsv</code>.</p>
<pre><code class="language-txt">Question includes ID
↓
Exact token search
↓
Find chunks containing that exact ID
↓
Add them to the candidate pool
</code></pre>
<p>This is not broad full-text search for every query.</p>
<p>It is a small fix for a known vector-search blind spot.</p>
<pre><code class="language-txt">Vector search
Good at meaning.

Exact token search
Good at exact codes.
</code></pre>
<p>Then both kinds of results go into the same candidate pool.</p>
<hr />
<h1>The third important RAG concept: reranking</h1>
<p>The candidate pool has 12 possible chunks.</p>
<p>Now we need to decide which ones are actually the best answer.</p>
<p>This is where reranking happens.</p>
<p>Reranking is done externally with Voyage too.</p>
<pre><code class="language-txt">Question + 12 candidates
↓
Voyage reranker
↓
Candidates reordered by relevance
↓
Keep the best 8
</code></pre>
<p>A reranker does something different from embeddings.</p>
<p>The embedding model sees the question and chunk separately.</p>
<p>The reranker sees them together.</p>
<pre><code class="language-txt">Question:
What happens when an invoice is overdue?

Chunk:
If payment remains unpaid for more than 30 days after its due date, the supplier may terminate this agreement.
</code></pre>
<p>The reranker can directly judge:</p>
<pre><code class="language-txt">Does this chunk answer this exact question?
</code></pre>
<p>This is called a cross-encoder.</p>
<pre><code class="language-txt">Bi-encoder
Question and chunk are processed separately.
Fast.
Good for finding candidates.

Cross-encoder
Question and chunk are processed together.
Slower.
Better at judging relevance.
</code></pre>
<p>You would not rerank one million chunks.</p>
<p>That would be expensive.</p>
<p>But reranking 12 already-good candidates is reasonable.</p>
<p>That is why the pipeline has two stages.</p>
<pre><code class="language-txt">Stage 1: cheap recall
Vector search finds possible chunks.

Stage 2: expensive precision
Reranker decides which possible chunks are best.
</code></pre>
<p>This is one of the main RAG ideas.</p>
<p>Use the cheap thing first.</p>
<p>Use the more accurate thing only on a small list.</p>
<hr />
<h1>What happens if reranking fails?</h1>
<p>Voyage is an external API.</p>
<p>It can fail.</p>
<p>The network can fail.</p>
<p>The API can be rate-limited.</p>
<p>So the app does not make reranking required for chat to work.</p>
<pre><code class="language-txt">Reranking works
↓
Use reranked top 8 chunks

Reranking fails
↓
Use the original vector order
</code></pre>
<p>The answer might be less precise.</p>
<p>But it is still grounded in retrieved chunks.</p>
<p>This is better than returning a complete error after retrieval already succeeded.</p>
<hr />
<h1>The LLM only receives the final chunks</h1>
<p>After vector retrieval, identifier search and reranking, the application has the final chunks.</p>
<p>For example:</p>
<pre><code class="language-txt">[1] Contract.pdf, page 3

If payment remains unpaid for more than 30 days after its due date, the supplier may terminate this agreement.
</code></pre>
<pre><code class="language-txt">[2] Contract.pdf, page 3

Termination does not remove the customer's obligation to pay all amounts already due.
</code></pre>
<p>Then the application builds the LLM prompt.</p>
<pre><code class="language-txt">Question:
What happens when an invoice is overdue?

Context:
[1] Contract.pdf, page 3
...

[2] Contract.pdf, page 3
...
</code></pre>
<p>The system prompt tells the LLM:</p>
<pre><code class="language-txt">Answer only from the provided context.

Cite the document and page.

If the answer is not in the context, say so.

Do not invent information.
</code></pre>
<p>This is grounding.</p>
<p>Grounding means that the LLM should use retrieved document text as the source of truth.</p>
<p>The LLM should not answer based on what it generally knows about contracts.</p>
<p>It should answer based on what this contract says.</p>
<hr />
<h1>“I don't know” is part of a good RAG app</h1>
<p>If no useful chunks are found, the app does not call the LLM.</p>
<p>Instead, it returns something like:</p>
<pre><code class="language-txt">I don't know based on the provided documents.
</code></pre>
<p>This is a feature.</p>
<p>A RAG app should not confidently invent an answer just because the user asked a question.</p>
<p>The goal is not:</p>
<pre><code class="language-txt">Always generate something.
</code></pre>
<p>The goal is:</p>
<pre><code class="language-txt">Answer from the uploaded documents.
</code></pre>
<p>If the documents do not contain the answer, the correct answer is that the system does not know.</p>
<hr />
<h1>Why questions are rewritten before retrieval</h1>
<p>Chat questions are often incomplete.</p>
<p>Imagine this conversation:</p>
<pre><code class="language-txt">User:
Tell me about the Mercedes EQS.

Assistant:
The Mercedes EQS is an electric luxury sedan...

User:
What about its warranty?
</code></pre>
<p>The last question is not good for vector retrieval.</p>
<pre><code class="language-txt">What about its warranty?
</code></pre>
<p>What does <code>its</code> mean?</p>
<p>So the app can rewrite the message using chat history.</p>
<pre><code class="language-txt">Original:
What about its warranty?

Retrieval query:
What is the warranty of the Mercedes EQS?
</code></pre>
<p>The rewritten question is used for retrieval.</p>
<p>The original question is still saved in chat.</p>
<p>The user sees what they actually typed.</p>
<p>The retriever gets a complete question.</p>
<hr />
<h1>RAG is not always the correct answer</h1>
<p>This is something I think is worth saying.</p>
<p>For a single small PDF, RAG can be worse than just sending the whole document to the LLM.</p>
<p>In my app, if the user selected exactly one small document and it fits under the token limit, the application skips vector search.</p>
<pre><code class="language-txt">One small selected document
↓
Load every chunk in reading order
↓
Send the full document as context
</code></pre>
<p>No embedding call.</p>
<p>No vector search.</p>
<p>No reranking.</p>
<p>This is useful because retrieval can accidentally miss the most relevant chunk.</p>
<p>For a small document, there is no need to take that risk.</p>
<p>RAG makes more sense when:</p>
<pre><code class="language-txt">Documents are large

There are many documents

Only a small part is relevant

You need efficient search

You need citations
</code></pre>
<p>The best RAG system is not the one that uses RAG everywhere.</p>
<p>It is the one that knows when not to use it.</p>
<hr />
<h1>What happens during ingestion, step by step</h1>
<p>This is the local and external work separated clearly.</p>
<pre><code class="language-txt">1. User uploads a PDF

2. The API validates it
   - signed-in user
   - file size
   - actual PDF bytes

3. The PDF file is saved locally

4. A document row is created in PostgreSQL
   status = uploaded

5. A BullMQ job is added to Redis

6. The worker receives:
   documentId
   ownerId

7. The worker reads the PDF bytes locally

8. unpdf extracts text locally, page by page

9. The worker rejects:
   corrupt PDFs
   too many pages
   scanned PDFs with no text

10. Local JavaScript splits page text into chunks

11. The chunks are sent to Voyage AI

12. Voyage AI returns one 1024-number vector per chunk

13. PostgreSQL stores:
   raw text
   vector
   page number
   document ID
   owner ID
   chunk order

14. The document status becomes ready
</code></pre>
<p>At this point, the PDF is searchable.</p>
<hr />
<h1>What happens when a user asks a question, step by step</h1>
<pre><code class="language-txt">1. User sends a chat message

2. The API checks:
   user
   message
   rate limit
   LLM configuration

3. The app loads chat history

4. The app rewrites the question when needed

5. The app checks whether one small document can use full context

6. If not:
   send the fresh question to Voyage AI

7. Voyage returns one fresh query vector

8. pgvector searches stored chunk vectors by cosine distance

9. Exact identifier search runs when the question contains a code or ID

10. The candidate pool goes to Voyage reranking

11. Voyage returns the best chunks in better order

12. The app builds context with:
    raw text
    document name
    page number

13. The LLM receives question + context

14. The answer streams back to the browser

15. The answer and citations are saved
</code></pre>
<hr />
<h1>The architecture in one final mind map</h1>
<pre><code class="language-txt">                         UPLOAD TIME

PDF
│
├── local storage
│
├── Node.js worker
│   │
│   ├── read bytes
│   ├── parse text with unpdf
│   ├── keep page numbers
│   └── split text into overlapping chunks
│
├── Voyage AI
│   │
│   └── turn every chunk into a 1024-number embedding
│
└── PostgreSQL + pgvector
    │
    └── store:
        raw chunk text
        chunk vector
        page number
        document ID
        owner ID
        chunk order


                         QUESTION TIME

User question
│
├── rewrite with chat history when needed
│
├── Voyage AI
│   │
│   └── turn the fresh question into a query vector
│
├── PostgreSQL + pgvector
│   │
│   ├── compare question vector with stored chunk vectors
│   ├── use cosine distance
│   ├── use HNSW for fast search
│   └── return likely relevant chunks
│
├── exact keyword search
│   │
│   └── find exact IDs, SKUs, invoice numbers and codes
│
├── Voyage reranker
│   │
│   └── reorder possible chunks by actual relevance
│
└── LLM
    │
    └── answer only from final chunks and cite the pages
</code></pre>
<hr />
<h1>The main idea I want to remember</h1>
<p>RAG is a data pipeline.</p>
<pre><code class="language-txt">First:
Turn PDFs into searchable pieces of text.

Then:
Turn those pieces into vectors.

Then:
Store text and vectors together.

Later:
Turn the user question into a fresh vector.

Then:
Find stored vectors with similar meaning.

Then:
Give the original text from those vectors to the LLM.
</code></pre>
<p>The embedding vectors help us find the answer.</p>
<p>The raw chunk text is the actual answer source.</p>
<p>The LLM explains that source to the user.</p>
<pre><code class="language-txt">Ingestion prepares the knowledge.

pgvector finds the knowledge.

The LLM explains the knowledge.
</code></pre>
<p>Good for you if you stayed this long ;)</p>
]]></content:encoded></item><item><title><![CDATA[Mind Blowing Cut Page Animation in React]]></title><description><![CDATA[We’re going to fake a page getting cut into vertical strips, then we’ll yank those strips up and down like blinds, revealing a brand-new scene underneath (in our case: a simple light-blue background).
https://codesandbox.io/embed/tfv76t?view=preview
...]]></description><link>https://featuringcode.com/mind-blowing-cut-page-animation-in-react</link><guid isPermaLink="true">https://featuringcode.com/mind-blowing-cut-page-animation-in-react</guid><category><![CDATA[React]]></category><category><![CDATA[animations]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 24 Jan 2026 21:20:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769289625700/bf1643d6-4300-403a-a32d-95d4205b7745.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We’re going to fake a page getting <strong>cut into vertical strips</strong>, then we’ll yank those strips <strong>up and down like blinds</strong>, revealing a <strong>brand-new scene</strong> underneath (in our case: a simple light-blue background).</p>
<p><a target="_blank" href="https://codesandbox.io/embed/tfv76t?view=preview">https://codesandbox.io/embed/tfv76t?view=preview</a></p>
<h2 id="heading-the-concept-paper-over-a-picture">The Concept: Paper Over a Picture</h2>
<p>Imagine a white sheet of paper with text on it, placed over a colorful picture.</p>
<p>You take a blade and make vertical cuts through the paper.</p>
<p>Then you lift alternating strips — some up, some down — revealing the picture underneath.</p>
<p>That’s exactly what this animation does:</p>
<ol>
<li><p><strong>Cutting phase</strong>: vertical lines animate in, simulating “cuts”</p>
</li>
<li><p><strong>Reveal phase</strong>: the “cut” strips slide away (up/down alternating)</p>
</li>
<li><p><strong>Scene phase</strong>: the new scene is fully visible</p>
</li>
</ol>
<h2 id="heading-the-wrap-that-makes-it-possible">The Wrap That Makes It Possible</h2>
<p>This line is the whole magic trick:</p>
<pre><code class="lang-javascript">&lt;AnimationOverlay isActive={isAnimationActive}&gt;
  {content}
&lt;/AnimationOverlay&gt;
</code></pre>
<h3 id="heading-what-it-means">What it means</h3>
<ul>
<li><p><code>AnimationOverlay</code> is a wrapper component.</p>
</li>
<li><p>It receives your whole page (<code>content</code>) as <code>children</code>.</p>
</li>
<li><p>It decides how to render that page depending on the animation <strong>phase</strong>:</p>
<ul>
<li><p>render normally (idle)</p>
</li>
<li><p>render the page + cutting lines (cutting)</p>
</li>
<li><p>render multiple clipped “slices” of the page (revealing)</p>
</li>
<li><p>render only the new scene (scene)</p>
</li>
</ul>
</li>
</ul>
<p>It’s basically a <strong>director</strong> standing behind the camera shouting:</p>
<blockquote>
<p>“Okay, show the page… NOW cut it… NOW rip it apart… NOW show the scene.”</p>
</blockquote>
<h2 id="heading-animationoverlay-the-orchestrator">AnimationOverlay: The Orchestrator</h2>
<h4 id="heading-1-idle">1) <code>idle</code></h4>
<p>Nothing fancy.<br />Just render children like normal.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">if</span> (!isActive || phase === <span class="hljs-string">"idle"</span>) <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;&gt;</span>{children}<span class="hljs-tag">&lt;/&gt;</span></span>;
</code></pre>
<h4 id="heading-2-cutting">2) <code>cutting</code></h4>
<p>We render:</p>
<ul>
<li><p>the page (children)</p>
</li>
<li><p>plus <code>AnimatedLines</code> in “cutting mode”</p>
</li>
</ul>
<pre><code class="lang-javascript">{children}
&lt;AnimatedLines phase=<span class="hljs-string">"cutting"</span> onComplete={handleCuttingComplete} /&gt;
</code></pre>
<p><code>AnimatedLines</code> draws vertical lines that slide into place, like blades.</p>
<p>When the last line finishes its animation, it calls <code>onComplete</code>, and the overlay moves to…</p>
<h4 id="heading-3-revealing">3) <code>revealing</code></h4>
<p>We render:</p>
<ul>
<li><p><code>AnimatedLines</code> in “revealing mode”</p>
</li>
<li><p>and pass the same children <em>again</em></p>
</li>
</ul>
<pre><code class="lang-javascript">&lt;AnimatedLines phase=<span class="hljs-string">"revealing"</span> onComplete={handleRevealComplete}&gt;
  {children}
&lt;/AnimatedLines&gt;
</code></pre>
<p>This is the big trick: in reveal mode, <code>AnimatedLines</code> doesn’t draw lines —<br />it creates <strong>multiple vertical slices</strong> of your page and animates them away.</p>
<p>Then we move to…</p>
<h4 id="heading-4-scene">4) <code>scene</code></h4>
<p>We stop rendering the page at all.<br />Only the background “scene” remains.</p>
<p>In your demo it’s just light-blue, full screen</p>
<h2 id="heading-animatedlines-two-personalities-in-one-component">AnimatedLines: Two Personalities in One Component</h2>
<p><code>AnimatedLines</code> has two completely different behaviors depending on <code>phase</code>.</p>
<h3 id="heading-a-cutting-phase-vertical-blades">A) Cutting phase: vertical blades</h3>
<p>We generate equal X positions across the screen:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> generateEqualCutPositions = <span class="hljs-function">(<span class="hljs-params">count, edgePaddingPct = <span class="hljs-number">6</span></span>) =&gt;</span> ...
</code></pre>
<p>This creates a list like:</p>
<ul>
<li><p>6%</p>
</li>
<li><p>14%</p>
</li>
<li><p>22%</p>
</li>
<li><p>...</p>
</li>
<li><p>94%</p>
</li>
</ul>
<p>So the cuts are <strong>evenly spaced</strong>.</p>
<p>Then we render each cut as a <code>&lt;div class="cut-line"&gt;</code> positioned by <code>left: ${xPos}%</code>.</p>
<p>We also alternate the direction:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> fromTop = i % <span class="hljs-number">2</span> === <span class="hljs-number">0</span>;
</code></pre>
<p>So one line comes from the top, the next from the bottom, etc.</p>
<p>It looks more “alive” than all lines falling from the same direction.</p>
<p>To know when we’re done, we count animations ending:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> handleCutEnd = <span class="hljs-function">() =&gt;</span> setCutsComplete(<span class="hljs-function">(<span class="hljs-params">prev</span>) =&gt;</span> prev + <span class="hljs-number">1</span>);
</code></pre>
<p>When <code>cutsComplete</code> matches number of lines, we call <code>onComplete()</code> and advance to reveal.</p>
<h3 id="heading-b-reveal-phase-slicing-the-page-into-strips">B) Reveal phase: slicing the page into strips</h3>
<p>This is the sneaky part.</p>
<p>We take all cut positions and build “sections”:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> createSections = <span class="hljs-function">(<span class="hljs-params">cutPositions</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> all = [<span class="hljs-number">0</span>, ...cutPositions, <span class="hljs-number">100</span>];
  <span class="hljs-keyword">return</span> all.slice(<span class="hljs-number">0</span>, <span class="hljs-number">-1</span>).map(<span class="hljs-function">(<span class="hljs-params">left, i</span>) =&gt;</span> ({
    left,
    <span class="hljs-attr">width</span>: all[i + <span class="hljs-number">1</span>] - left,
  }));
};
</code></pre>
<p>So if your cuts are at <code>10%, 30%, 50%...</code><br />Then sections become:</p>
<ul>
<li><p>0% → 10%</p>
</li>
<li><p>10% → 30%</p>
</li>
<li><p>30% → 50%</p>
</li>
<li><p>etc…</p>
</li>
</ul>
<p>Each section renders the <strong>entire page</strong> again… but only <em>shows its own slice</em>.</p>
<p>That’s how we “cut” the page without actually cutting it.</p>
<h2 id="heading-the-clip-path-trick">The Clip-Path Trick</h2>
<p>Each section uses:</p>
<pre><code class="lang-javascript">clipPath: <span class="hljs-string">`inset(0 <span class="hljs-subst">${right}</span>% 0 <span class="hljs-subst">${left}</span>%)`</span>
</code></pre>
<h3 id="heading-why-clip-path">Why clip-path?</h3>
<p>Because we want each slice to show only a vertical segment of the page.</p>
<p>We could try:</p>
<ul>
<li><p>cropping with overflow + nested wrappers</p>
</li>
<li><p>manually splitting layout</p>
</li>
<li><p>rendering separate content</p>
</li>
</ul>
<p>…but <code>clip-path</code> is clean: it masks the element visually while keeping layout intact.</p>
<h3 id="heading-why-inset-specifically">Why <code>inset()</code> specifically?</h3>
<p><code>clip-path: inset(top right bottom left)</code> is perfect for “rectangular slicing”.</p>
<p>We want:</p>
<ul>
<li><p>top = 0</p>
</li>
<li><p>bottom = 0</p>
</li>
<li><p>left = start of slice</p>
</li>
<li><p>right = everything beyond slice</p>
</li>
</ul>
<p>So each slice becomes:</p>
<ul>
<li><p>“show only this vertical window”</p>
</li>
<li><p>“hide the rest”</p>
</li>
</ul>
<p>And because we define it in percentages, it scales nicely with viewport size.</p>
<p>This is why your reveal phase is possible without rewriting the page layout.</p>
<h2 id="heading-the-goes-up-goes-down-alternation">The “Goes Up / Goes Down” Alternation</h2>
<p>For each slice:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> goesUp = i % <span class="hljs-number">2</span> === <span class="hljs-number">0</span>;
<span class="hljs-string">"--direction"</span>: goesUp ? <span class="hljs-string">"-1"</span> : <span class="hljs-string">"1"</span>
</code></pre>
<p>Then CSS does:</p>
<pre><code class="lang-javascript">transform: translateY(calc(<span class="hljs-keyword">var</span>(--direction) * <span class="hljs-number">100</span>vh));
</code></pre>
<p>So slices alternate:</p>
<ul>
<li><p>slice 0 → up</p>
</li>
<li><p>slice 1 → down</p>
</li>
<li><p>slice 2 → up</p>
</li>
<li><p>slice 3 → down</p>
</li>
</ul>
<p>That alternating motion is what sells the illusion that the page was “cut”.</p>
<p>If they all went the same direction, it would feel like a normal page slide transition.</p>
<h2 id="heading-stylescss-performance-corner-the-good-stuff">styles.css: Performance Corner (The Good Stuff)</h2>
<h3 id="heading-will-change-transform"><code>will-change: transform;</code></h3>
<p>You’ll see this on <code>.cut-line</code> and <code>.section-reveal</code>.</p>
<p>What it tells the browser:</p>
<blockquote>
<p>“Hey, I’m about to animate <code>transform</code>. Please prepare for that.”</p>
</blockquote>
<p>Browsers can then:</p>
<ul>
<li><p>promote the element to its own compositor layer</p>
</li>
<li><p>reduce paint work during animation</p>
</li>
<li><p>avoid stutter when the animation starts</p>
</li>
</ul>
<p>It’s basically pre-warming the engine.</p>
<p><strong>Important caveat</strong>: don’t sprinkle <code>will-change</code> everywhere.<br />It consumes memory if abused.</p>
<p>Here it’s used on a few animated elements, so it’s a good fit.</p>
<h3 id="heading-why-we-animate-with-transform-translatey">Why we animate with <code>transform: translateY(...)</code></h3>
<p>We deliberately avoid animating layout properties like <code>top</code>, <code>left</code>, <code>height</code>.</p>
<p>Layout properties cause:</p>
<ul>
<li><p>layout recalculation</p>
</li>
<li><p>paint</p>
</li>
<li><p>potential jank</p>
</li>
</ul>
<p>Transforms are compositor-friendly:</p>
<ul>
<li><p>they usually run on the GPU</p>
</li>
<li><p>no layout thrashing</p>
</li>
<li><p>smoother on weaker devices</p>
</li>
</ul>
<p>That’s why our keyframes are:</p>
<pre><code class="lang-javascript">@keyframes section-slide {
  <span class="hljs-number">0</span>% { <span class="hljs-attr">transform</span>: translateY(<span class="hljs-number">0</span>); }
  <span class="hljs-number">100</span>% { <span class="hljs-attr">transform</span>: translateY(calc(<span class="hljs-keyword">var</span>(--direction) * <span class="hljs-number">100</span>vh)); }
}
</code></pre>
<p>We’re pushing the slice off-screen by exactly one viewport height.</p>
<h3 id="heading-the-section-slide-animation">The section-slide animation</h3>
<pre><code class="lang-javascript">animation: section-slide <span class="hljs-keyword">var</span>(--reveal-duration, <span class="hljs-number">3000</span>ms)
  cubic-bezier(<span class="hljs-number">0.16</span>, <span class="hljs-number">1</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">1</span>) forwards;
</code></pre>
<p>Breakdown:</p>
<ul>
<li><p><code>section-slide</code><br />  the keyframes that move the slice</p>
</li>
<li><p><code>var(--reveal-duration, 3000ms)</code><br />  uses a CSS variable if provided, otherwise defaults to 3000ms</p>
</li>
<li><p><code>cubic-bezier(0.16, 1, 0.3, 1)</code> this easing is “fast start, slow settle” it makes the animation feel physical, not linear / robotic (it’s like: rip it hard, then let it drift)</p>
</li>
<li><p><code>forwards</code><br />  keeps the final transform applied otherwise, the strips would snap back when the animation ends<br />  (and that would destroy the illusion instantly)</p>
</li>
</ul>
<h2 id="heading-a-small-line-with-big-impact-contain-strict">A Small Line With Big Impact: <code>contain: strict</code></h2>
<p>At some point in the animation code you’ll see something like this:</p>
<pre><code class="lang-javascript">&lt;div style={{ <span class="hljs-attr">contain</span>: <span class="hljs-string">"strict"</span> }}&gt;
  {<span class="hljs-comment">/* animated content */</span>}
&lt;/div&gt;
</code></pre>
<p>This single CSS property can dramatically reduce the amount of work the browser has to do during complex animations.</p>
<h2 id="heading-what-contain-actually-means">What <code>contain</code> Actually Means</h2>
<p><code>contain</code> is a CSS performance hint. It tells the browser:</p>
<blockquote>
<p>“Everything inside this element is self-contained.<br />Nothing in here affects the outside world.”</p>
</blockquote>
<p>In other words:</p>
<ul>
<li><p>layout changes inside won’t affect layout outside</p>
</li>
<li><p>paint changes inside won’t require repainting ancestors</p>
</li>
<li><p>style recalculations are isolated</p>
</li>
</ul>
<p>When you write:</p>
<pre><code class="lang-javascript">contain: strict;
</code></pre>
<p>You are enabling <strong>all containment types at once</strong>:</p>
<pre><code class="lang-javascript">contain: layout paint style size;
</code></pre>
<p>This is the strongest possible form of containment.</p>
<h2 id="heading-why-this-matters-for-animations">Why This Matters for Animations</h2>
<p>During the reveal phase, we do something fairly aggressive:</p>
<ul>
<li><p>render <strong>multiple copies</strong> of the entire page</p>
</li>
<li><p>clip each copy into vertical slices</p>
</li>
<li><p>animate every slice simultaneously</p>
</li>
<li><p>move them off-screen with transforms</p>
</li>
</ul>
<p>Without containment, the browser has to <em>consider the possibility</em> that:</p>
<ul>
<li><p>moving one slice might affect layout elsewhere</p>
</li>
<li><p>painting one slice might invalidate large areas</p>
</li>
<li><p>style changes might cascade upward</p>
</li>
</ul>
<p>Even if it doesn’t <em>actually</em> happen, the browser still has to <strong>check</strong>.</p>
<p>That checking is where performance dies.</p>
<p><code>contain: strict</code> tells the browser:</p>
<blockquote>
<p>“Stop checking.<br />This subtree is sealed.”</p>
</blockquote>
<hr />
<h2 id="heading-what-the-browser-can-skip">What the Browser Can Skip</h2>
<p>With <code>contain: strict</code>, the browser is free to:</p>
<ul>
<li><p><strong>Skip layout propagation</strong><br />  Moving slices won’t trigger reflows outside the container</p>
</li>
<li><p><strong>Limit paint invalidation</strong><br />  Only the contained area needs repainting</p>
</li>
<li><p><strong>Isolate style recalculation</strong><br />  CSS changes don’t bubble up the DOM tree</p>
</li>
</ul>
<p>This is especially important when:</p>
<ul>
<li><p>you animate many elements at once</p>
</li>
<li><p>those elements are visually large</p>
</li>
<li><p>you duplicate content (like we do for slices)</p>
</li>
</ul>
<h2 id="heading-why-this-pairs-perfectly-with-transform">Why This Pairs Perfectly With <code>transform</code></h2>
<p>Our animation already follows best practices:</p>
<ul>
<li><p>only animates <code>transform</code> and <code>opacity</code></p>
</li>
<li><p>avoids layout-affecting properties</p>
</li>
<li><p>uses <code>will-change: transform</code></p>
</li>
</ul>
<p><code>contain: strict</code> completes the picture.</p>
<p>Think of it like this:</p>
<ul>
<li><p><code>transform</code> keeps animations on the compositor</p>
</li>
<li><p><code>will-change</code> preps the GPU</p>
</li>
<li><p><code>contain</code> limits the blast radius</p>
</li>
</ul>
<p>Together, they prevent:</p>
<ul>
<li><p>layout thrashing</p>
</li>
<li><p>unnecessary repaints</p>
</li>
<li><p>accidental main-thread work</p>
</li>
</ul>
<hr />
<h2 id="heading-when-not-to-use-contain-strict">When <em>Not</em> to Use <code>contain: strict</code></h2>
<p>This is not a free optimization.</p>
<p>You <strong>should not</strong> use <code>contain: strict</code> when:</p>
<ul>
<li><p>the element’s size depends on its children</p>
</li>
<li><p>children need to affect surrounding layout</p>
</li>
<li><p>you rely on percentage sizing relative to ancestors</p>
</li>
<li><p>positioned elements need to escape the container</p>
</li>
</ul>
<p>In our case:</p>
<ul>
<li><p>the animation container is full-screen</p>
</li>
<li><p>its size is fixed</p>
</li>
<li><p>its contents are visually isolated</p>
</li>
</ul>
<p>So it’s a perfect fit.</p>
<h2 id="heading-mental-model">Mental Model</h2>
<p>A good way to think about <code>contain: strict</code>:</p>
<blockquote>
<p>“This is a mini universe.<br />Physics inside don’t leak out.”</p>
</blockquote>
<p>For animation-heavy UIs, that’s exactly what you want.</p>
<h2 id="heading-why-this-matters-more-than-you-think">Why This Matters More Than You Think</h2>
<p>Most animation jank doesn’t come from bad easing curves.</p>
<p>It comes from:</p>
<ul>
<li><p>browsers doing <em>extra work you didn’t ask for</em></p>
</li>
<li><p>layout and paint costs you didn’t realise you triggered</p>
</li>
</ul>
<p><code>contain: strict</code> is you telling the browser:</p>
<blockquote>
<p>“Relax. I’ve got this under control.”</p>
</blockquote>
<p>And when paired with transform-based animations, it’s one of the cleanest performance wins you can get in modern CSS — especially in animations that look way more expensive than they actually are.</p>
]]></content:encoded></item><item><title><![CDATA[Mastering Drums with the Web Audio API]]></title><description><![CDATA[Ever wondered how browser-based games and music apps make sounds without loading a single audio file?
No MP3s. No WAVs. Just JavaScript.
I learnt this while creating a small game which you can play here: https://beat-bird.vercel.app/
The Web Audio AP...]]></description><link>https://featuringcode.com/mastering-drums-with-the-web-audio-api</link><guid isPermaLink="true">https://featuringcode.com/mastering-drums-with-the-web-audio-api</guid><category><![CDATA[Web Audio]]></category><category><![CDATA[Web Audio API]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 10 Jan 2026 17:24:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1768065690651/9ad12548-0038-49e7-bac7-1f75c27efc58.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Ever wondered how browser-based games and music apps make sounds <strong>without loading a single audio file</strong>?</p>
<p>No MP3s. No WAVs. Just JavaScript.</p>
<p>I learnt this while creating a small game which you can play here: <a target="_blank" href="https://beat-bird.vercel.app/">https://beat-bird.vercel.app/</a></p>
<p>The <strong>Web Audio API</strong> lets you synthesize sound from scratch using oscillators, noise, filters, and envelopes. In this article, I’ll show how I built a <strong>complete drum kit</strong> for a rhythm game using nothing but code—and how you can do the same.</p>
<div class="embed-wrapper"><div class="embed-loading"><div class="loadingRow"></div><div class="loadingRow"></div></div><a class="embed-card" href="https://codesandbox.io/embed/c4pvq5?view=preview">https://codesandbox.io/embed/c4pvq5?view=preview</a></div>
<p> </p>
<p>If you’ve never worked with audio before, the Web Audio API can feel mysterious.</p>
<p><strong><em>Oscillators. Filters. Noise. Frequencies.</em></strong></p>
<p>It sounds like music theory—but it isn’t.</p>
<p>This post is about understanding <strong>what sound actually is in code</strong>, and how a few simple building blocks let us create convincing drum and game sounds entirely in JavaScript.</p>
<p>No audio files. No libraries. Just numbers.</p>
<hr />
<h2 id="heading-what-sound-really-is-in-code-terms">What Sound Really Is (In Code Terms)</h2>
<p>Let’s remove the mystery first.</p>
<p>Sound is <strong>just air moving back and forth</strong>.<br />Your speakers do that by receiving a stream of numbers.</p>
<ul>
<li><p>Big number → speaker moves out</p>
</li>
<li><p>Small number → speaker moves in</p>
</li>
</ul>
<p>If you change those numbers fast enough (about 44,100 times per second), your brain hears sound.</p>
<p>The Web Audio API is just a system for <strong>generating and modifying those numbers</strong>.</p>
<h2 id="heading-audiocontext-a-factory-for-sound">AudioContext: A Factory for Sound</h2>
<p>An <code>AudioContext</code> is not a “player”.</p>
<p>It’s a <strong>sound factory</strong>.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> audioContext = <span class="hljs-keyword">new</span> AudioContext()
</code></pre>
<p>Inside it, you create nodes that:</p>
<ul>
<li><p>generate numbers</p>
</li>
<li><p>modify numbers</p>
</li>
<li><p>send numbers to your speakers</p>
</li>
</ul>
<p>These nodes are connected together into an <strong>audio graph</strong>.</p>
<blockquote>
<p>Think of it like a data pipeline, but for sound.</p>
</blockquote>
<p>Audio always flows <strong>left to right</strong>:</p>
<blockquote>
<p><strong>Source → processing → destination</strong></p>
</blockquote>
<h2 id="heading-oscillators-repeating-patterns">Oscillators: Repeating Patterns</h2>
<p>An oscillator generates a repeating pattern of numbers.</p>
<p>That pattern is called a <strong>waveform</strong>.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> osc = audioContext.createOscillator()
osc.type = <span class="hljs-string">"sine"</span>
osc.frequency.value = <span class="hljs-number">440</span>
</code></pre>
<p>This does <strong>not</strong> mean “play a note”.</p>
<p>It means:</p>
<blockquote>
<p>“Repeat this shape 440 times per second.”</p>
</blockquote>
<p>Different shapes → different feelings.</p>
<p>An oscillator produces a continuous tone. What that tone <em>sounds like</em> depends on its waveform.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Waveform</td><td>Character</td><td>Common Uses</td></tr>
</thead>
<tbody>
<tr>
<td>sine</td><td>Pure and smooth</td><td>Sub bass, low-end, flutes</td></tr>
<tr>
<td>square</td><td>Hollow, retro</td><td>Chiptunes, leads</td></tr>
<tr>
<td>sawtooth</td><td>Bright and aggressive</td><td>Brass, strings, effects</td></tr>
<tr>
<td>triangle</td><td>Soft but textured</td><td>Percussion, wooden sounds</td></tr>
</tbody>
</table>
</div><p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768065287771/113b647f-a077-4efa-a01a-06599c62c460.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-the-most-important-concept-envelopes">The Most Important Concept: Envelopes</h2>
<p>An <strong>envelope</strong> is how a value changes over time.</p>
<p>In sound design, envelopes usually control:</p>
<ul>
<li><p>volume</p>
</li>
<li><p>pitch</p>
</li>
</ul>
<pre><code class="lang-javascript">gain.gain.setValueAtTime(<span class="hljs-number">1</span>, t)
gain.gain.exponentialRampToValueAtTime(<span class="hljs-number">0.01</span>, t + <span class="hljs-number">0.15</span>)
</code></pre>
<blockquote>
<p>Drums don’t stop instantly.<br />They lose energy.</p>
</blockquote>
<p>These two lines describe <strong>how energy fades over time</strong>.<br />That shape is what turns a tone into a drum.</p>
<hr />
<h3 id="heading-gaingain-is-just-a-multiplier"><code>gain.gain</code> Is Just a Multiplier</h3>
<p>A <code>GainNode</code> doesn’t know anything about music.<br />It simply multiplies numbers.</p>
<pre><code class="lang-javascript">output = input * gain
</code></pre>
<p>So when you see:</p>
<pre><code class="lang-javascript">gain.gain.setValueAtTime(<span class="hljs-number">1</span>, t)
</code></pre>
<p>It means:</p>
<blockquote>
<p>At time <code>t</code>, let the sound pass through unchanged.</p>
</blockquote>
<ul>
<li><p><code>1</code> → full strength</p>
</li>
<li><p><code>0.5</code> → half strength</p>
</li>
<li><p><code>0</code> → silence</p>
</li>
</ul>
<p>This isn’t decibels or anything fancy—just a number.</p>
<h3 id="heading-the-fade-out-is-the-sound-exponentialramptovalueattime">The Fade-Out Is the Sound - <code>exponentialRampToValueAtTime</code></h3>
<p><strong>Our ears hear logarithmically</strong>, not linearly.</p>
<p>Exponential ramps feel natural. Linear ramps sound artificial.</p>
<pre><code class="lang-javascript">gain.gain.exponentialRampToValueAtTime(<span class="hljs-number">0.01</span>, t + <span class="hljs-number">0.15</span>)
</code></pre>
<p>This line says:</p>
<blockquote>
<p>Over the next 150ms, smoothly reduce the volume to almost zero.</p>
</blockquote>
<p>Why not <code>0</code>?</p>
<p>Because exponential ramps can’t reach zero—and <code>0.01</code> is already inaudible.</p>
<h3 id="heading-conceptually-the-envelope-creates"><strong>Conceptually, the envelope creates:</strong></h3>
<pre><code class="lang-javascript">Volume
<span class="hljs-number">1.0</span> |\
    | \
    |  \
    |   \
<span class="hljs-number">0.0</span> |____\____ Time
       <span class="hljs-number">150</span>ms
</code></pre>
<ul>
<li><p>Instant attack (the hit)</p>
</li>
<li><p>Fast exponential decay (energy loss)</p>
</li>
</ul>
<p>That <em>shape</em> is the drum.</p>
<p>An exponential fade sounds like <strong>energy dissipating</strong>.</p>
<p>That’s how:</p>
<ul>
<li><p>drum heads stop vibrating</p>
</li>
<li><p>strings lose motion</p>
</li>
<li><p>physical objects settle</p>
</li>
</ul>
<p>This is why almost every percussive sound uses exponential decay.</p>
<hr />
<h2 id="heading-time-works-differently-in-audio">Time Works Differently in Audio</h2>
<h3 id="heading-the-audio-engine-has-its-own-clock">The Audio Engine Has Its Own Clock</h3>
<p>The Web Audio API runs on a <strong>dedicated, high-precision clock</strong> that is completely independent from the browser’s event loop.</p>
<p>You access that clock through:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> t = audioContext.currentTime
</code></pre>
<p>This value:</p>
<ul>
<li><p>is measured in <strong>seconds</strong></p>
</li>
<li><p>increases continuously</p>
</li>
<li><p>is extremely stable</p>
</li>
<li><p>does <strong>not</strong> pause when JavaScript is busy</p>
</li>
</ul>
<h3 id="heading-scheduling-slightly-in-the-future">Scheduling Slightly in the Future</h3>
<p>You’ll almost always see:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> t = audioContext.currentTime + <span class="hljs-number">0.05</span>
</code></pre>
<p>That extra <code>0.05</code> seconds (50ms) does two things:</p>
<ol>
<li><p>Gives the audio engine time to schedule everything cleanly</p>
</li>
<li><p>Prevents clicks and timing jitter</p>
</li>
</ol>
<p>You’re telling the engine:</p>
<blockquote>
<p>“I’m planning ahead. Please play this precisely.”</p>
</blockquote>
<hr />
<h2 id="heading-frequency-how-pitch-is-created">Frequency: How Pitch Is Created</h2>
<p>When we say:</p>
<blockquote>
<p>“That person has a high-pitched voice”</p>
</blockquote>
<p>We’re not describing <em>loudness</em>.<br />We’re describing <strong>how fast something is vibrating</strong>.</p>
<p>A high-pitched voice means:</p>
<blockquote>
<p>The vocal cords are opening and closing <strong>very quickly</strong>.</p>
</blockquote>
<p>That motion pushes air in and out faster, which produces <strong>higher frequencies</strong>.</p>
<hr />
<h3 id="heading-voices-and-oscillators-are-doing-the-same-thing">Voices and Oscillators Are Doing the Same Thing</h3>
<p>Your vocal cords work a lot like an oscillator:</p>
<ul>
<li><p>They vibrate</p>
</li>
<li><p>They repeat a pattern</p>
</li>
<li><p>That pattern pushes air</p>
</li>
</ul>
<p>A deep voice vibrates slowly.<br />A high voice vibrates quickly.</p>
<p>The Web Audio API just replaces vocal cords with math.</p>
<pre><code class="lang-javascript">osc.frequency.value = <span class="hljs-number">100</span>
</code></pre>
<p>→ slow vibration → deep sound</p>
<pre><code class="lang-javascript">osc.frequency.value = <span class="hljs-number">1000</span>
</code></pre>
<p>→ fast vibration → high sound</p>
<p>Your brain hears <em>speed</em> as <em>pitch</em>.</p>
<h3 id="heading-why-this-still-works-without-music-theory">Why This Still Works Without Music Theory</h3>
<p>You don’t need to know notes or scales.</p>
<p>Your brain evolved to interpret:</p>
<ul>
<li><p>slow vibrations as “big”</p>
</li>
<li><p>fast vibrations as “small or sharp”</p>
</li>
</ul>
<p>That’s why:</p>
<ul>
<li><p>big animals sound deep</p>
</li>
<li><p>small animals sound high</p>
</li>
<li><p>tense situations feel higher-pitched</p>
</li>
</ul>
<p>The Web Audio API just gives you direct access to that perception.</p>
<h3 id="heading-one-sentence-to-remember">One Sentence to Remember</h3>
<blockquote>
<p>Pitch is how fast something vibrates.<br />Frequency is how we describe that speed.</p>
</blockquote>
<h2 id="heading-noise-where-order-becomes-chaos">Noise: Where Order Becomes Chaos</h2>
<p>Up until now, every sound we’ve created has been <strong>predictable</strong>.</p>
<p>Oscillators repeat a clean pattern:</p>
<ul>
<li><p>same shape</p>
</li>
<li><p>same speed</p>
</li>
<li><p>same result every time</p>
</li>
</ul>
<p>But real-world sounds—especially percussion—aren’t like that.</p>
<p>They’re messy.</p>
<p>That’s where <strong>noise</strong> comes in.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> noise = ctx.createBufferSource();
</code></pre>
<p>This line does <strong>not</strong> create noise by itself.</p>
<p>It creates a <strong>player</strong>—something that can play a chunk of audio data.</p>
<p>The “noise” comes from <strong>what we put into it</strong>.</p>
<hr />
<h3 id="heading-noise-is-just-random-numbers">Noise Is Just Random Numbers</h3>
<p>Earlier, we created a buffer like this:</p>
<pre><code class="lang-javascript">data[i] = <span class="hljs-built_in">Math</span>.random() * <span class="hljs-number">2</span> - <span class="hljs-number">1</span>;
</code></pre>
<p>That means:</p>
<blockquote>
<p>At every audio sample, move the speaker to a random position.</p>
</blockquote>
<p>No pattern.<br />No repetition.<br />Just randomness.</p>
<p>When played fast enough, that randomness becomes <strong>static</strong>.</p>
<hr />
<h3 id="heading-why-we-use-a-buffer-instead-of-an-oscillator">Why We Use a Buffer Instead of an Oscillator</h3>
<p>An oscillator produces order:</p>
<ul>
<li><p>repeatable</p>
</li>
<li><p>smooth</p>
</li>
<li><p>stable</p>
</li>
</ul>
<p>Noise is the opposite:</p>
<ul>
<li><p>unpredictable</p>
</li>
<li><p>rough</p>
</li>
<li><p>chaotic</p>
</li>
</ul>
<p>You can’t generate that with an oscillator.</p>
<p>So instead, we:</p>
<ol>
<li><p>Create random values</p>
</li>
<li><p>Store them in a buffer</p>
</li>
<li><p>Play that buffer</p>
</li>
</ol>
<pre><code class="lang-javascript">noise.buffer = noiseBuffer;
noise.start(when);
</code></pre>
<hr />
<h3 id="heading-what-createbuffersource-really-means">What <code>createBufferSource()</code> Really Means</h3>
<p>A <code>BufferSource</code> is best thought of as:</p>
<blockquote>
<p>“Play this array of numbers as sound.”</p>
</blockquote>
<p>It doesn’t generate sound.<br />It doesn’t modify sound.<br />It just <strong>replays data</strong>.</p>
<hr />
<h3 id="heading-why-noise-is-essential-for-percussion">Why Noise Is Essential for Percussion</h3>
<p>Many real-world sounds are <strong>not tonal</strong>:</p>
<ul>
<li><p>snares</p>
</li>
<li><p>hi-hats</p>
</li>
<li><p>shakers</p>
</li>
<li><p>explosions</p>
</li>
<li><p>wind</p>
</li>
<li><p>crashes</p>
</li>
</ul>
<p>These sounds don’t vibrate in a stable way.</p>
<p>They’re friction, collisions, and chaos.</p>
<p>Noise gives us that chaos.</p>
<hr />
<h3 id="heading-noise-by-itself-is-too-much">Noise by Itself Is Too Much</h3>
<p>Raw noise contains:</p>
<ul>
<li><p>low rumble</p>
</li>
<li><p>mid clutter</p>
</li>
<li><p>sharp highs</p>
</li>
</ul>
<p>That’s why noise alone rarely sounds good.</p>
<p>We shape it with:</p>
<ul>
<li><p>filters (to remove unwanted frequencies)</p>
</li>
<li><p>envelopes (to make it short-lived)</p>
</li>
</ul>
<p>Noise is the raw material.<br />Filters and envelopes turn it into something usable.</p>
<hr />
<h3 id="heading-the-important-mental-model">The Important Mental Model</h3>
<ul>
<li><p>Oscillators = predictable motion</p>
</li>
<li><p>Noise = random motion</p>
</li>
<li><p>Filters = selective removal</p>
</li>
<li><p>Envelopes = energy over time</p>
</li>
</ul>
<p>Once you see noise as <strong>intentional randomness</strong>, it stops feeling strange—and starts feeling necessary.</p>
<hr />
<h2 id="heading-filters-removing-information-on-purpose">Filters: Removing Information on Purpose</h2>
<p>A filter does exactly one thing:</p>
<blockquote>
<p>It removes parts of the sound.</p>
</blockquote>
<hr />
<h3 id="heading-what-a-filter-actually-does">What a Filter Actually Does</h3>
<p>When you write:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> filter = ctx.createBiquadFilter()
filter.type = <span class="hljs-string">"highpass"</span>
filter.frequency.value = <span class="hljs-number">1000</span>
</code></pre>
<p>You’re saying:</p>
<blockquote>
<p>Remove everything below 1000Hz.<br />Keep only the fast, sharp movement.</p>
</blockquote>
<p>A <strong>high-pass filter</strong>:</p>
<ul>
<li><p>lets high frequencies through</p>
</li>
<li><p>removes low frequencies</p>
</li>
</ul>
<p>A <strong>low-pass filter</strong> does the opposite.</p>
<p>No sound is added.<br />Only information is removed.</p>
<h3 id="heading-filters-shape-chaos-into-something-recognizable">Filters Shape Chaos into Something Recognizable</h3>
<p>This is the real “aha” moment.</p>
<ul>
<li><p>Oscillators create order</p>
</li>
<li><p>Noise creates chaos</p>
</li>
<li><p>Filters carve that chaos into a shape</p>
</li>
<li><p>Envelopes give it life</p>
</li>
</ul>
<p>A snare works not because it’s complex,<br />but because it’s <strong>controlled randomness</strong>.</p>
<hr />
<h3 id="heading-frequency-filters-character">Frequency + Filters = Character</h3>
<p>Two sounds can have:</p>
<ul>
<li><p>the same envelope</p>
</li>
<li><p>the same timing</p>
</li>
</ul>
<p>But feel completely different because:</p>
<ul>
<li><p>one has more high frequencies</p>
</li>
<li><p>the other has more low frequencies</p>
</li>
</ul>
<p>Filters decide <em>where</em> the energy lives.</p>
<hr />
<h3 id="heading-a-simple-rule-of-thumb">A Simple Rule of Thumb</h3>
<p>If a sound feels:</p>
<ul>
<li><p><strong>muddy</strong> → remove low frequencies</p>
</li>
<li><p><strong>too sharp</strong> → remove high frequencies</p>
</li>
<li><p><strong>too plain</strong> → let more frequencies through</p>
</li>
</ul>
<hr />
<h2 id="heading-building-a-kick-drum-pitch-is-energy">Building a Kick Drum (Pitch Is Energy)</h2>
<p>A kick drum’s “thump” comes from a <strong>rapid drop in pitch</strong>.</p>
<p>A kick drum sounds deep, but it <strong>starts high</strong>.</p>
<p>We simulate that with a frequency envelope:</p>
<pre><code class="lang-javascript">  <span class="hljs-keyword">const</span> playKick = <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> t = now(ctx);
    <span class="hljs-keyword">const</span> osc = ctx.createOscillator();
    <span class="hljs-keyword">const</span> gain = ctx.createGain();

    osc.connect(gain);
    gain.connect(master);

    osc.frequency.setValueAtTime(<span class="hljs-number">150</span>, t);
    osc.frequency.exponentialRampToValueAtTime(<span class="hljs-number">50</span>, t + <span class="hljs-number">0.1</span>);

    gain.gain.setValueAtTime(<span class="hljs-number">0.8</span>, t);
    gain.gain.exponentialRampToValueAtTime(<span class="hljs-number">0.01</span>, t + <span class="hljs-number">0.15</span>);

    osc.start(t);
    osc.stop(t + <span class="hljs-number">0.15</span>);
  }
</code></pre>
<p>High → low in a fraction of a second.</p>
<p>Your brain hears that as <strong>impact</strong>.</p>
<h2 id="heading-snare-drum-tone-noise">Snare Drum = Tone + Noise</h2>
<p>A snare isn’t just a drum head. It’s also <strong>metal wires rattling underneath</strong>.</p>
<p>We synthesize those two components separately.</p>
<h3 id="heading-white-noise-the-crack">White Noise (the “crack”)</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> bufferSize = ctx.sampleRate
<span class="hljs-keyword">const</span> noiseBuffer = ctx.createBuffer(<span class="hljs-number">1</span>, bufferSize, ctx.sampleRate)
<span class="hljs-keyword">const</span> data = noiseBuffer.getChannelData(<span class="hljs-number">0</span>)

<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; bufferSize; i++) {
  data[i] = <span class="hljs-built_in">Math</span>.random() * <span class="hljs-number">2</span> - <span class="hljs-number">1</span>
}
</code></pre>
<p>Then we filter and envelope it:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> playSnare = (when: number): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">const</span> noise = ctx.createBufferSource()
  <span class="hljs-keyword">const</span> filter = ctx.createBiquadFilter()
  <span class="hljs-keyword">const</span> gain = ctx.createGain()

  noise.buffer = noiseBuffer
  filter.type = <span class="hljs-string">"highpass"</span>
  filter.frequency.value = <span class="hljs-number">1000</span>

  noise.connect(filter)
  filter.connect(gain)
  gain.connect(ctx.destination)

  gain.gain.setValueAtTime(<span class="hljs-number">0.2</span>, when)
  gain.gain.exponentialRampToValueAtTime(<span class="hljs-number">0.01</span>, when + <span class="hljs-number">0.1</span>)

  noise.start(when)
  noise.stop(when + <span class="hljs-number">0.1</span>)

  playTone(<span class="hljs-number">180</span>, <span class="hljs-number">0.08</span>, <span class="hljs-string">"triangle"</span>, when)
}
</code></pre>
<p>The result: a tight, punchy snare with zero samples.</p>
<h2 id="heading-error-sounds-and-game-feedback">Error Sounds and Game Feedback</h2>
<p>Sound design is also UX.</p>
<p>A <strong>descending pitch</strong> universally signals failure.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> playMiss = (when: number): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">const</span> osc = ctx.createOscillator()

  osc.type = <span class="hljs-string">"sawtooth"</span>
  osc.frequency.setValueAtTime(<span class="hljs-number">300</span>, when)
  osc.frequency.exponentialRampToValueAtTime(<span class="hljs-number">100</span>, when + <span class="hljs-number">0.15</span>)

  osc.start(when)
  osc.stop(when + <span class="hljs-number">0.15</span>)
}
</code></pre>
<p>No explanation needed. Your brain just <em>gets it</em>.</p>
<h2 id="heading-more-sounds-using-the-same-building-blocks">More Sounds Using the Same Building Blocks</h2>
<h3 id="heading-hi-hat-closed">Hi-Hat (Closed)</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> playHiHat = (when: number): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">const</span> noise = ctx.createBufferSource()
  <span class="hljs-keyword">const</span> filter = ctx.createBiquadFilter()
  <span class="hljs-keyword">const</span> gain = ctx.createGain()

  noise.buffer = noiseBuffer
  filter.type = <span class="hljs-string">"highpass"</span>
  filter.frequency.value = <span class="hljs-number">7000</span>

  noise.connect(filter)
  filter.connect(gain)
  gain.connect(ctx.destination)

  gain.gain.setValueAtTime(<span class="hljs-number">0.3</span>, when)
  gain.gain.exponentialRampToValueAtTime(<span class="hljs-number">0.01</span>, when + <span class="hljs-number">0.05</span>)

  noise.start(when)
  noise.stop(when + <span class="hljs-number">0.05</span>)
}
</code></pre>
<h3 id="heading-tom-drums">Tom Drums</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> playTom = (when: number, <span class="hljs-attr">pitch</span>: <span class="hljs-string">"high"</span> | <span class="hljs-string">"mid"</span> | <span class="hljs-string">"low"</span>): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">const</span> map = { <span class="hljs-attr">high</span>: <span class="hljs-number">200</span>, <span class="hljs-attr">mid</span>: <span class="hljs-number">150</span>, <span class="hljs-attr">low</span>: <span class="hljs-number">100</span> }
  <span class="hljs-keyword">const</span> osc = ctx.createOscillator()
  <span class="hljs-keyword">const</span> gain = ctx.createGain()

  osc.frequency.setValueAtTime(map[pitch] * <span class="hljs-number">1.5</span>, when)
  osc.frequency.exponentialRampToValueAtTime(map[pitch], when + <span class="hljs-number">0.1</span>)

  gain.gain.setValueAtTime(<span class="hljs-number">0.6</span>, when)
  gain.gain.exponentialRampToValueAtTime(<span class="hljs-number">0.01</span>, when + <span class="hljs-number">0.3</span>)

  osc.connect(gain)
  gain.connect(ctx.destination)

  osc.start(when)
  osc.stop(when + <span class="hljs-number">0.3</span>)
}
</code></pre>
<h2 id="heading-the-ios-safari-audio-unlock-trap">The iOS Safari Audio “Unlock” Trap</h2>
<p>One thing that <em>will</em> bite you on mobile Safari:</p>
<blockquote>
<p>Audio must be unlocked by a <strong>user gesture</strong></p>
</blockquote>
<p>Even calling <code>resume()</code> isn’t always enough.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> unlockAudio = (): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">if</span> (ctx.state === <span class="hljs-string">"suspended"</span>) {
    ctx.resume()
  }

  <span class="hljs-keyword">const</span> buffer = ctx.createBuffer(<span class="hljs-number">1</span>, <span class="hljs-number">1</span>, <span class="hljs-number">22050</span>)
  <span class="hljs-keyword">const</span> source = ctx.createBufferSource()
  source.buffer = buffer
  source.connect(ctx.destination)
  source.start()
}

<span class="hljs-built_in">document</span>.body.addEventListener(<span class="hljs-string">"touchstart"</span>, unlockAudio)
<span class="hljs-built_in">document</span>.body.addEventListener(<span class="hljs-string">"touchend"</span>, unlockAudio)
</code></pre>
<p>This silent buffer trick is the difference between “works on desktop” and “works everywhere”.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>By now, every piece should fit into a single mental model.</p>
<ul>
<li><p><strong>Oscillators</strong> create order<br />  A predictable, repeating motion.</p>
</li>
<li><p><strong>Noise</strong> creates chaos<br />  Random movement with no pattern.</p>
</li>
<li><p><strong>Raw chaos is unusable</strong><br />  It’s too wide, too messy, too much information.</p>
</li>
<li><p><strong>Filters remove what you don’t want</strong><br />  Carving chaos into something recognizable.</p>
</li>
<li><p><strong>Envelopes give everything life</strong><br />  Energy appears, fades, and disappears.</p>
</li>
<li><p><strong>Time</strong> simply tells the system <strong>when</strong> all of this happens.</p>
</li>
</ul>
<p>With all of these you can build:</p>
<ul>
<li><p>Full drum kits</p>
</li>
<li><p>UI sounds</p>
</li>
<li><p>Game effects</p>
</li>
<li><p>Musical arpeggios</p>
</li>
</ul>
<p>No assets to load. No bandwidth wasted. Infinite variation.</p>
<p>If you enjoy understanding <em>how things work</em> instead of just importing libraries, the Web Audio API is deeply rewarding.</p>
<p>Sound design is just programming—your ears are the debugger.</p>
]]></content:encoded></item><item><title><![CDATA[Docker Intro for DUMMIES (like me)]]></title><description><![CDATA[I don’t know why, but Docker seems confusing for me, that’s why I started learning some more about it. As a senior frontend engineer, I don’t like being at the hand of the senior backend so this is what I learned so far:
Let’s say I want to create a ...]]></description><link>https://featuringcode.com/docker-intro-for-dummies-like-me</link><guid isPermaLink="true">https://featuringcode.com/docker-intro-for-dummies-like-me</guid><category><![CDATA[Docker]]></category><category><![CDATA[docker images]]></category><category><![CDATA[docker container]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Mon, 11 Nov 2024 22:21:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1731363607192/0e219fd5-ff1f-41d1-8f1b-a622625079be.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I don’t know why, but Docker seems confusing for me, that’s why I started learning some more about it. As a senior frontend engineer, I don’t like being at the hand of the senior backend so this is what I learned so far:</p>
<p>Let’s say I want to create a simple Nodejs app. My project is located in <code>“D:/Projects/docker-tutorial”</code></p>
<ol>
<li><h2 id="heading-create-an-appindexjs-for-the-nodejs-app">Create an <code>app/index.js</code> for the Nodejs app.</h2>
</li>
</ol>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> express = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express'</span>);
<span class="hljs-keyword">const</span> app = express();

app.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
    res.send(<span class="hljs-string">'Hello World from node'</span>);
});

app.listen(<span class="hljs-number">8080</span>, <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Server is running on port 8080 '</span>);
});
</code></pre>
<ol start="2">
<li><h2 id="heading-next-create-a-packagejson">Next create a <code>package.json</code></h2>
</li>
</ol>
<pre><code class="lang-javascript">{
  <span class="hljs-string">"name"</span>: <span class="hljs-string">"docker-tuts"</span>,
  <span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0.0"</span>,
  <span class="hljs-string">"description"</span>: <span class="hljs-string">""</span>,
  <span class="hljs-string">"main"</span>: <span class="hljs-string">"index.js"</span>,
  <span class="hljs-string">"scripts"</span>: {
    <span class="hljs-string">"test"</span>: <span class="hljs-string">"echo \"Error: no test specified\" &amp;&amp; exit 1"</span>
  },
  <span class="hljs-string">"author"</span>: <span class="hljs-string">""</span>,
  <span class="hljs-string">"license"</span>: <span class="hljs-string">"ISC"</span>,
  <span class="hljs-string">"dependencies"</span>: {
    <span class="hljs-string">"express"</span>: <span class="hljs-string">"^4.21.1"</span>
  }
}
</code></pre>
<p>What I want to do is take this small app, copy it somewhere, install the dependencies and run it. For this, I will use Docker.</p>
<ol start="3">
<li><h2 id="heading-create-a-file-called-dockerfile">Create a file called <code>dockerfile</code></h2>
</li>
</ol>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">FROM</span> node:<span class="hljs-number">22</span>-alpine

<span class="hljs-keyword">WORKDIR</span><span class="bash"> /home/node/app</span>

<span class="hljs-keyword">COPY</span><span class="bash"> package*.json ./</span>

<span class="hljs-keyword">RUN</span><span class="bash"> npm install</span>

<span class="hljs-keyword">COPY</span><span class="bash"> app/ .</span>

<span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"node"</span>, <span class="hljs-string">"index.js"</span>]</span>
</code></pre>
<p>This is the “recipe” for your containers. This will create an image.</p>
<p>The <mark>image </mark> is the <mark>recipe</mark>. The image represents the instructions to build the containers.</p>
<p>The <mark>containers </mark> are the <mark>dishes</mark>. The containers contain the logic and <code>node_modules</code>.</p>
<h3 id="heading-first-lets-explain-what-is-happening">First let’s explain what is happening:</h3>
<p><strong>Step 1: Specify the Base Image</strong></p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">FROM</span> node:<span class="hljs-number">22</span>-alpine
</code></pre>
<p>We start from the official Node.js image based on Alpine Linux for a lightweight container.</p>
<p><strong>Step 2: Set the Working Directory</strong></p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">WORKDIR</span><span class="bash"> /home/node/app</span>
</code></pre>
<p>This sets the working directory inside the container where all subsequent commands will be executed and where your logic and node_modules will stay.</p>
<p><strong>Step 3: Copy the package.json</strong></p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">COPY</span><span class="bash"> package*.json ./</span>
</code></pre>
<p>This copies <code>package.json</code> and <code>package-lock.json</code> (if it exists) into the working directory.</p>
<p><strong>Step 4: Install Dependencies</strong></p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">RUN</span><span class="bash"> npm install</span>
</code></pre>
<p>Installs the dependencies specified in <code>package.json</code> inside the working directory.</p>
<p><strong>Step 5: Copy Application Code</strong></p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">COPY</span><span class="bash"> app/ .</span>
</code></pre>
<p>Copies your application code <code>app/index.js</code> into the container <code>/home/node/app/</code>.</p>
<p><strong>Step 6: Specify the Command to Run the App</strong></p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"node"</span>, <span class="hljs-string">"index.js"</span>]</span>
</code></pre>
<p>Defines the command to start your application when the container starts.</p>
<h3 id="heading-the-behavior-of-the-copy-command-depends-on-whether-you-include-a-trailing-slash">The behavior of the <code>COPY</code> command depends on whether you include a trailing slash:</h3>
<ul>
<li><p><strong>With trailing slash</strong> <code>COPY app/ ./destination/</code>: Copies the <em>contents</em> of the <code>app</code> directory into a new directory called <code>destination</code>, inside the container resulting in <code>/home/node/app/destination/index.js</code>.</p>
</li>
<li><pre><code class="lang-javascript">    # Result <span class="hljs-keyword">in</span> container:
    # /home/node/app/destination/
    # └── index.js
</code></pre>
</li>
<li><p><strong>Without trailing slash</strong> <code>COPY app ./destination/</code>: Copies the entire <code>app</code> directory into <code>/home/node/app/destination/</code>, resulting in <code>/home/node/app/destination/app/index.js</code>.</p>
</li>
<li><pre><code class="lang-javascript">    # Result <span class="hljs-keyword">in</span> container:
    # /home/node/app/destination/
    # └── app/
    #     └── index.js
</code></pre>
</li>
<li><p>Using just . or ./ for the destination <code>COPY app/ .</code>: Copies the <em>contents</em> of the <code>app</code> directory into the current directory inside the container (<code>/home/node/app/</code>), so <code>index.js</code> ends up directly inside <code>/home/node/app/</code>.</p>
</li>
<li><pre><code class="lang-javascript">    # Result <span class="hljs-keyword">in</span> container:
    # /home/node/app/
    # └── index.js
</code></pre>
</li>
</ul>
<h2 id="heading-in-the-console-you-create-the-image">In the console you create the image</h2>
<p>In <code>“D:/Projects/docker-tutorial”</code> you can run</p>
<pre><code class="lang-bash">docker build . -t myNodeImg
</code></pre>
<ul>
<li><p>This will build the image inside docker and this image will contain the instructions</p>
</li>
<li><p><code>-t myNodeImg</code> - will give the name of the image</p>
</li>
<li><p>notice the <code>.</code> - this is the directory where the <code>dockerfile</code> exists</p>
</li>
</ul>
<p>Then you can create a container (or multiple) with</p>
<p><code>docker run --name myNodeContainer1 -p 80:8080 -d myNodeImg</code></p>
<ul>
<li><p>This will create a named container named <code>myNodeContainer1</code></p>
</li>
<li><p>it will link the port (<code>-p</code>) from your computer to the port inside the container, where <code>-p &lt;host_port&gt;:&lt;container_port&gt;</code>. In this case, <code>-p 80:8080</code> maps port <code>80</code> on your computer (host) to port <code>8080</code> inside the container.</p>
</li>
<li><p>it will not block your terminal, by being detached from it (<code>-d</code>)</p>
</li>
<li><p>it will know to create the container from the image <code>myNodeImg</code></p>
</li>
</ul>
<p>Now when you go to the browser on http://localhost:80 you should see the message “Hello World from node”.</p>
<p>That’s it for now but stay tuned for more.</p>
]]></content:encoded></item><item><title><![CDATA[Customizing React-Select with TypeScript: A Developer’s Alchemy]]></title><description><![CDATA[If you're reading this article because you think customizing the react-select library with TypeScript is going to be a walk in the park—close this tab right now. After combing through the documentation, I, too, was under the impression that this woul...]]></description><link>https://featuringcode.com/customizing-react-select-with-typescript-a-developers-alchemy</link><guid isPermaLink="true">https://featuringcode.com/customizing-react-select-with-typescript-a-developers-alchemy</guid><category><![CDATA[React]]></category><category><![CDATA[react-select]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Sat, 09 Sep 2023 10:54:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1694257127216/39770ef8-0f3d-4873-9fff-a5d154423ae7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you're reading this article because you think customizing the <a target="_blank" href="https://www.npmjs.com/package/react-select"><code>react-select</code></a> library with TypeScript is going to be a walk in the park—close this tab right now. After combing through the documentation, I, too, was under the impression that this would be a plug-and-play endeavor. Man, was I in for a surprise!</p>
<p>Still here? Cool, let's dive right in.</p>
<h4 id="heading-the-magic-behind-react-select">The Magic Behind React-Select</h4>
<p>For those who don't know, <code>react-select</code> is a highly customizable replacement for the HTML <code>&lt;select&gt;</code> element. It works exceptionally well with React, providing numerous ways to style the dropdown and the options within.</p>
<h4 id="heading-how-to-create-custom-options-with-html-templates">How To Create Custom Options with HTML Templates</h4>
<p>Before diving into the code, let's understand that <code>react-select</code> gives you a very nifty <code>components</code> prop. This allows you to override any part of the UI.</p>
<p>Here's a quick example:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> React <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> Select, { components } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-select'</span>;

<span class="hljs-keyword">const</span> CustomOption = <span class="hljs-function">(<span class="hljs-params">props: any</span>) =&gt;</span> (
  <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">components.Option</span> {<span class="hljs-attr">...props</span>}&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">strong</span>&gt;</span>{props.data.label}<span class="hljs-tag">&lt;/<span class="hljs-name">strong</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">small</span>&gt;</span>{props.data.description}<span class="hljs-tag">&lt;/<span class="hljs-name">small</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">components.Option</span>&gt;</span></span>
);

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> MySelect = <span class="hljs-function">() =&gt;</span> (
  <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Select</span>
    <span class="hljs-attr">components</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">Option:</span> <span class="hljs-attr">CustomOption</span> }}
    <span class="hljs-attr">options</span>=<span class="hljs-string">{[</span>
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Apple</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">apple</span>', <span class="hljs-attr">description:</span> '<span class="hljs-attr">A</span> <span class="hljs-attr">red</span> <span class="hljs-attr">fruit</span>' },
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Banana</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">banana</span>', <span class="hljs-attr">description:</span> '<span class="hljs-attr">A</span> <span class="hljs-attr">yellow</span> <span class="hljs-attr">fruit</span>' }
    ]}
  /&gt;</span></span>
);
</code></pre>
<p>If you want to add more finesse to your options, you can use the <code>isDisabled, isFocused, isSelected</code> props.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> React <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> Select, { components } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-select'</span>;

<span class="hljs-keyword">const</span> CustomOption = <span class="hljs-function">(<span class="hljs-params">props: any</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> { isDisabled, isFocused, isSelected } = props;

  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">components.Option</span> {<span class="hljs-attr">...props</span>}&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">opacity:</span> <span class="hljs-attr">isDisabled</span> ? <span class="hljs-attr">0.5</span> <span class="hljs-attr">:</span> <span class="hljs-attr">1</span> }}&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">strong</span>&gt;</span>{props.data.label}<span class="hljs-tag">&lt;/<span class="hljs-name">strong</span>&gt;</span>
        { isSelected &amp;&amp; <span class="hljs-tag">&lt;<span class="hljs-name">span</span>&gt;</span> •<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span> }
        <span class="hljs-tag">&lt;<span class="hljs-name">small</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">color:</span> <span class="hljs-attr">isFocused</span> ? '<span class="hljs-attr">blue</span>' <span class="hljs-attr">:</span> '<span class="hljs-attr">grey</span>' }}&gt;</span>
          {props.data.description}
        <span class="hljs-tag">&lt;/<span class="hljs-name">small</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">components.Option</span>&gt;</span></span>
  );
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> MySelect = <span class="hljs-function">() =&gt;</span> (
  <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Select</span>
    <span class="hljs-attr">components</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">Option:</span> <span class="hljs-attr">CustomOption</span> }}
    <span class="hljs-attr">options</span>=<span class="hljs-string">{[</span>
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Apple</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">apple</span>', <span class="hljs-attr">description:</span> '<span class="hljs-attr">A</span> <span class="hljs-attr">red</span> <span class="hljs-attr">fruit</span>' },
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Banana</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">banana</span>', <span class="hljs-attr">description:</span> '<span class="hljs-attr">A</span> <span class="hljs-attr">yellow</span> <span class="hljs-attr">fruit</span>' }
    ]}
  /&gt;</span></span>
);
</code></pre>
<p>Now you can make that option look the way you want.</p>
<h4 id="heading-styling-the-menu-and-menulist-giving-your-dropdown-some-sass">Styling the Menu and MenuList: Giving Your Dropdown Some Sass</h4>
<p>So you know:</p>
<ul>
<li><p>the <strong>menu</strong>: is essentially the container that wraps around all the options you see when the dropdown is activated. It is responsible for the placement, size, and other container-specific styling of the dropdown list. Customizing <code>menu</code> will affect the dropdown's outer box.</p>
</li>
<li><p>the <strong>menuList</strong>: is a child component within the <code>menu</code> and directly wraps around the individual option items. Customizing <code>menuList</code> will affect the styling and layout of the options inside the <code>menu</code> but not the external container itself.</p>
</li>
</ul>
<p>Think of <code>menu</code> as the outer shell and <code>menuList</code> as the inner lining. Any customization you apply to these components will alter how the dropdown appears and behaves when opened.</p>
<p>Customizing the style in <code>react-select</code> is made possible with the <code>styles</code> prop. You can easily change the border radius, color, and many other styles.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> customStyles = {
  <span class="hljs-attr">menu</span>: <span class="hljs-function">(<span class="hljs-params">provided: any</span>) =&gt;</span> ({
    ...provided,
    <span class="hljs-attr">borderRadius</span>: <span class="hljs-string">'0px'</span>,
    <span class="hljs-attr">backgroundColor</span>: <span class="hljs-string">'lightgray'</span>
  }),
  <span class="hljs-attr">menuList</span>: <span class="hljs-function">(<span class="hljs-params">provided: any</span>) =&gt;</span> ({
    ...provided,
    <span class="hljs-attr">padding</span>: <span class="hljs-string">'0px'</span>
  })
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> MyStyledSelect = <span class="hljs-function">() =&gt;</span> (
  <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Select</span>
    <span class="hljs-attr">styles</span>=<span class="hljs-string">{customStyles}</span>
    <span class="hljs-attr">options</span>=<span class="hljs-string">{[</span>
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Apple</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">apple</span>' },
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Banana</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">banana</span>' }
    ]}
  /&gt;</span></span>
);
</code></pre>
<p>Just remember that some of the styles might need an <code>!important</code> next to them or else they won't show.</p>
<h4 id="heading-lets-jazz-up-that-input">Let's Jazz Up That Input</h4>
<p>The input box itself can also be styled, just like the menu and options. Simply target <code>control</code> in the <code>styles</code> prop.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> inputStyles = {
  <span class="hljs-attr">control</span>: <span class="hljs-function">(<span class="hljs-params">provided: any</span>) =&gt;</span> ({
    ...provided,
    <span class="hljs-attr">borderRadius</span>: <span class="hljs-string">'15px'</span>,
    <span class="hljs-attr">borderColor</span>: <span class="hljs-string">'#3f51b5'</span>
  })
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> MyInputStyledSelect = <span class="hljs-function">() =&gt;</span> (
  <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Select</span>
    <span class="hljs-attr">styles</span>=<span class="hljs-string">{inputStyles}</span>
    <span class="hljs-attr">options</span>=<span class="hljs-string">{[</span>
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Apple</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">apple</span>' },
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Banana</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">banana</span>' }
    ]}
  /&gt;</span></span>
);
</code></pre>
<p><strong>Don't forget about the menuPosition</strong></p>
<p>The menuPosition is another vital setting when you're customizing your dropdowns, and it's often mistaken for <code>menuPlacement</code>. While <code>menuPlacement</code> determines the location of the menu in terms of above or below the control, <code>menuPosition</code> goes further to dictate how the dropdown menu is positioned in the DOM.</p>
<p>Here are the options you can set for the <code>menuPosition</code> prop:</p>
<ul>
<li><p><code>absolute</code>: This is the default value. The menu is rendered within the DOM hierarchy of the control. It won't break out of a parent element with <code>overflow: hidden</code> set. So, if your <code>react-select</code> component is inside a container that hides overflow, the dropdown might not be displayed properly.</p>
</li>
<li><p><code>fixed</code>: The menu is rendered at the root level of the DOM hierarchy, so it's not constrained by a parent's <code>overflow: hidden</code> style. This setting allows the dropdown to break out of any containing element, ensuring visibility.</p>
</li>
</ul>
<p>Here's how you'd set it in code:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> Select <span class="hljs-keyword">from</span> <span class="hljs-string">'react-select'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> MyFixedMenuSelect = <span class="hljs-function">() =&gt;</span> (
  <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Select</span>
    <span class="hljs-attr">menuPosition</span>=<span class="hljs-string">"fixed"</span>
    <span class="hljs-attr">options</span>=<span class="hljs-string">{[</span>
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Apple</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">apple</span>' },
      { <span class="hljs-attr">label:</span> '<span class="hljs-attr">Banana</span>', <span class="hljs-attr">value:</span> '<span class="hljs-attr">banana</span>' }
    ]}
  /&gt;</span></span>
);
</code></pre>
<p>In this example, by setting the <code>menuPosition</code> to "fixed", the dropdown will be appended to the root level of the DOM and will not be confined by a parent element with <code>overflow: hidden</code>.</p>
<p>Think of <code>menuPosition</code> as the setup for how your dropdown behaves in a broader context within the DOM, much like how middleware affects an entire application. It gives you the control to adapt your dropdown menu to various scenarios and layout constraints. Choose wisely based on your application's needs!</p>
<h4 id="heading-the-aha-moment">The Aha Moment</h4>
<p>By now, you should be feeling pretty comfortable manipulating <code>react-select</code> with TypeScript.</p>
<p>The best part? You can have these complex styles and custom options up and running faster than you can say "JavaScript fatigue."</p>
<p>That’s all, folks.</p>
<p>Happy coding! 🚀</p>
]]></content:encoded></item><item><title><![CDATA[Teleport react components in your page]]></title><description><![CDATA[Whenever you need to take content with functionality from a component and put it somewhere else on the page, you can use a react portal. 
For example, if you have an input component that outputs some text in a div, you can pass that div to a portal a...]]></description><link>https://featuringcode.com/teleport-react-components-in-your-page</link><guid isPermaLink="true">https://featuringcode.com/teleport-react-components-in-your-page</guid><category><![CDATA[React]]></category><category><![CDATA[ReactHooks]]></category><category><![CDATA[portal]]></category><dc:creator><![CDATA[Mihai Marinescu]]></dc:creator><pubDate>Mon, 04 Jul 2022 06:41:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1656916625982/rYS6PsTFb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Whenever you need to take content with functionality from a component and put it somewhere else on the page, you can use a <a target="_blank" href="https://reactjs.org/docs/portals.html">react portal</a>. </p>
<p>For example, if you have an input component that outputs some text in a div, you can pass that div to a portal and render it in another component or place on the page. </p>
<p>How portals basically work is as follows:</p>
<ul>
<li>you pass a react element and a DOM element to a function <code>createPortal</code></li>
<li><code>createPortal</code> attaches the react element to the DOM element</li>
<li>you take the DOM element and append it in a place in the DOM</li>
</ul>
<p>This is useful when you have design constraints that require you to pass data and behavior from a react component to other places in the same page. Note that the source and the target need to be in the same page, meaning that the component that is passing the data should be in the same page as the target that is receiving that data.</p>
<p>Find a detailed video explanation here</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/oRNmO-xPUP4"></iframe>

<p>For this, you can create a reusable component</p>
<pre><code><span class="hljs-keyword">import</span> { <span class="hljs-title">FunctionComponent</span>, <span class="hljs-title">PropsWithChildren</span>, <span class="hljs-title">useEffect</span>, <span class="hljs-title">useRef</span>, <span class="hljs-title">useState</span> } <span class="hljs-title"><span class="hljs-keyword">from</span></span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title">createPortal</span> } <span class="hljs-title"><span class="hljs-keyword">from</span></span> <span class="hljs-string">'react-dom'</span>;

<span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">ComponentProps</span> </span>{
    id: <span class="hljs-keyword">string</span>
}

export const Portal: FunctionComponent<span class="hljs-operator">&lt;</span>PropsWithChildren<span class="hljs-operator">&lt;</span>ComponentProps<span class="hljs-operator">&gt;</span><span class="hljs-operator">&gt;</span> <span class="hljs-operator">=</span> ({ id, children }):JSX.Element <span class="hljs-operator">=</span><span class="hljs-operator">&gt;</span> {
  const domElementToAppend <span class="hljs-operator">=</span> useRef<span class="hljs-operator">&lt;</span>HTMLDivElement<span class="hljs-operator">&gt;</span>();
  const [isClientSide, setIsClientSide] <span class="hljs-operator">=</span> useState(<span class="hljs-literal">false</span>);

  useEffect(() <span class="hljs-operator">=</span><span class="hljs-operator">&gt;</span> {
    const target <span class="hljs-operator">=</span> document.getElementById(id);
    domElementToAppend.current <span class="hljs-operator">=</span> document.createElement(<span class="hljs-string">'div'</span>);

    <span class="hljs-keyword">if</span> (<span class="hljs-operator">!</span>domElementToAppend.current) {
      <span class="hljs-keyword">return</span>;
    }

    target?.appendChild(domElementToAppend.current);

    setIsClientSide(<span class="hljs-literal">true</span>);

    <span class="hljs-keyword">return</span> () <span class="hljs-operator">=</span><span class="hljs-operator">&gt;</span> {
      <span class="hljs-keyword">if</span> (domElementToAppend.current) {
        target?.removeChild(domElementToAppend.current);
      }
    };
  }, []);

  <span class="hljs-keyword">return</span> (
    <span class="hljs-operator">&lt;</span><span class="hljs-operator">&gt;</span>
      {
        (isClientSide <span class="hljs-operator">&amp;</span><span class="hljs-operator">&amp;</span> domElementToAppend.current) <span class="hljs-operator">&amp;</span><span class="hljs-operator">&amp;</span> createPortal(children, domElementToAppend.current)
      }
    <span class="hljs-operator">&lt;</span><span class="hljs-operator">/</span><span class="hljs-operator">&gt;</span>
  );
};
</code></pre><p>You need to create an html element, <code>domElementToAppend</code>. This is a ref element, because you need to initialize it in the <code>useEffect</code> hook as a DOM component, and then, the actual data is passed from the <code>createPortal</code> call in the template.</p>
<p>For server side rendered pages you need to make sure that the <code>createPortal</code> is called only when you are on the client side and after you have initialized <code>domElementToAppend</code> as a DOM component, else you won't see anything in the template at first render.</p>
<p>The <code>target</code> is an actual DOM element that is already in the page. This is the place where you will attach the data from the component that sends data to the portal.</p>
<p>Then you use this in your components like so:</p>
<pre><code><span class="hljs-keyword">import</span> <span class="hljs-title">React</span>, { <span class="hljs-title">ChangeEvent</span>, <span class="hljs-title">FunctionComponent</span>, <span class="hljs-title">useState</span> } <span class="hljs-title"><span class="hljs-keyword">from</span></span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title">Portal</span> } <span class="hljs-title"><span class="hljs-keyword">from</span></span> <span class="hljs-string">'../shared/Portal'</span>;

export const InputToDiv: FunctionComponent<span class="hljs-operator">&lt;</span>any<span class="hljs-operator">&gt;</span> <span class="hljs-operator">=</span> (): JSX.Element <span class="hljs-operator">=</span><span class="hljs-operator">&gt;</span> {
  const [text, setText] <span class="hljs-operator">=</span> useState(<span class="hljs-string">''</span>);
  const handleChange <span class="hljs-operator">=</span> (<span class="hljs-function"><span class="hljs-keyword">event</span>: <span class="hljs-title">ChangeEvent</span>&lt;<span class="hljs-title">HTMLInputElement</span>&gt;) =&gt; </span>{
    setText(<span class="hljs-keyword">event</span>.target.<span class="hljs-built_in">value</span>);
  };

  <span class="hljs-comment">//</span>
  const handleClick <span class="hljs-operator">=</span> (<span class="hljs-function"><span class="hljs-keyword">event</span>: <span class="hljs-title">React</span>.<span class="hljs-title">MouseEvent</span>&lt;<span class="hljs-title">HTMLDivElement</span>&gt;) =&gt; </span>{
    <span class="hljs-keyword">event</span>.preventDefault();
    console.log(<span class="hljs-string">'clicked!!!!'</span>);
  };

  <span class="hljs-keyword">return</span> (
    <span class="hljs-operator">&lt;</span>div onClick<span class="hljs-operator">=</span>{handleClick}<span class="hljs-operator">&gt;</span>
      <span class="hljs-operator">&lt;</span>input <span class="hljs-keyword">type</span><span class="hljs-operator">=</span><span class="hljs-string">"text"</span> onChange<span class="hljs-operator">=</span>{handleChange}<span class="hljs-operator">/</span><span class="hljs-operator">&gt;</span>
      <span class="hljs-operator">&lt;</span>Portal id<span class="hljs-operator">=</span><span class="hljs-string">"my-input-portal"</span><span class="hljs-operator">&gt;</span>
        <span class="hljs-operator">&lt;</span>div<span class="hljs-operator">&gt;</span>
          <span class="hljs-operator">&lt;</span>strong<span class="hljs-operator">&gt;</span>Typed text <span class="hljs-keyword">is</span> {text}<span class="hljs-operator">&lt;</span><span class="hljs-operator">/</span>strong<span class="hljs-operator">&gt;</span>
        <span class="hljs-operator">&lt;</span><span class="hljs-operator">/</span>div<span class="hljs-operator">&gt;</span>
      <span class="hljs-operator">&lt;</span><span class="hljs-operator">/</span>Portal<span class="hljs-operator">&gt;</span>
    <span class="hljs-operator">&lt;</span><span class="hljs-operator">/</span>div<span class="hljs-operator">&gt;</span>
  );
};
</code></pre><p>And then, in the actual page you need both the component and the portal</p>
<pre><code><span class="hljs-comment">//...</span>
        <span class="hljs-operator">&lt;</span>InputToDiv <span class="hljs-operator">/</span><span class="hljs-operator">&gt;</span>
<span class="hljs-comment">//...</span>

<span class="hljs-operator">&lt;</span>div id<span class="hljs-operator">=</span><span class="hljs-string">"my-input-portal"</span><span class="hljs-operator">&gt;</span><span class="hljs-operator">&lt;</span><span class="hljs-operator">/</span>div<span class="hljs-operator">&gt;</span>
</code></pre><p>You can find the code <a target="_blank" href="https://github.com/mmswi/nextjs-starter/tree/feature/portals">here</a></p>
]]></content:encoded></item></channel></rss>