<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sergey</title>
    <description>The latest articles on DEV Community by Sergey (@bocharov).</description>
    <link>https://dev.to/bocharov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4100026%2Fcb8b318e-c99e-40d3-afb9-460033e0ea08.png</url>
      <title>DEV Community: Sergey</title>
      <link>https://dev.to/bocharov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bocharov"/>
    <language>en</language>
    <item>
      <title>Virtualization Isn't Enough: Making Long Lists Actually Fast</title>
      <dc:creator>Sergey</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:18:13 +0000</pubDate>
      <link>https://dev.to/bocharov/virtualization-isnt-enough-making-long-lists-actually-fast-4cn2</link>
      <guid>https://dev.to/bocharov/virtualization-isnt-enough-making-long-lists-actually-fast-4cn2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7ca5m9zyfgiq35d0eei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7ca5m9zyfgiq35d0eei.png" alt="Virtual Lists: From O(n) to O(log n)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Imagine a chat with a hundred thousand messages. Twenty of them fit on the screen.&lt;/p&gt;

&lt;p&gt;If you create a DOM element for every message, the browser has to keep track of all hundred thousand elements, even though the user sees only a small part of the history. Virtualization solves this problem: it keeps the visible messages and a small buffer around them in the DOM, and represents the rest of the space with an empty block called a spacer.&lt;/p&gt;

&lt;p&gt;The screen still feels like a long history, but the browser doesn't have to render every message at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtualization doesn't solve every problem
&lt;/h2&gt;

&lt;p&gt;To show the right messages, the component has to figure out which rows fall inside the viewport. If every row has the same height, that's easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But chat messages vary. One takes up two lines, another takes twenty. An image may load after the text, and resizing the window changes how lines wrap. A message's height isn't known precisely until it has been rendered.&lt;/p&gt;

&lt;p&gt;So a virtual list has to solve three related but distinct problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Quickly find the row that corresponds to a given scroll position.&lt;/li&gt;
&lt;li&gt;Update a row's height once its actual size becomes known.&lt;/li&gt;
&lt;li&gt;Keep the message being read in place when the heights of rows above it change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can find rows quickly and still get scroll jumps. You can preserve the reading position and still spend too much time computing coordinates. A reliable list needs both solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How virtualization works
&lt;/h2&gt;

&lt;p&gt;A virtual list usually contains a full-height spacer and a separate container with the visible rows. The container is offset by the distance from the top of the list to the first rendered row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;History:  [ hidden rows ][──── viewport ────][ hidden rows ]

DOM:                     [visible + buffer]

Spacer:   ···············│                  │··············
                         ↑ window start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visible range is often extended by a few rows above and below. This buffer is called &lt;code&gt;overscan&lt;/code&gt;: it helps avoid blank areas during fast scrolling.&lt;/p&gt;

&lt;p&gt;In simplified form, the markup might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`translateY(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visibleRows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;totalHeight&lt;/code&gt; is the combined height of the entire history, and &lt;code&gt;top&lt;/code&gt; is the coordinate where the first rendered row begins. A real component also has to handle container positioning, row measurement, and size updates.&lt;/p&gt;

&lt;p&gt;If every row's height is known and fixed, finding the window is easy. With variable heights, it gets harder: you need to quickly determine which row contains &lt;code&gt;scrollTop&lt;/code&gt;, and then the coordinate where the window starts.&lt;/p&gt;

&lt;p&gt;Let's start with a simple solution and add complexity only when we hit a concrete limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1. Linear scan: simple while the list is small
&lt;/h2&gt;

&lt;p&gt;The most obvious approach is to add up message heights from the top of the list until the running total reaches &lt;code&gt;scrollTop&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findRowLinear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// The list is empty, or the coordinate lies beyond the end of the list.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The algorithm checks rows one by one. If the user is near the top of the list, the search finishes quickly. If they've scrolled almost to the end of the chat, it has to check nearly all hundred thousand messages.&lt;/p&gt;

&lt;p&gt;In the worst case, lookup time is proportional to the number of rows: &lt;strong&gt;O(n)&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Start of list                                        End
[1][2][3][4][5][6][7][8][9][10] ... [99991] ... [100000]
 └───────────────────────────────────────────────→ search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem isn't the number of DOM elements: virtualization has already capped that. The problem is that computing the visible window still walks the entire list to find a row.&lt;/p&gt;

&lt;p&gt;You can cut some overhead by storing heights in a &lt;code&gt;Float64Array&lt;/code&gt;, maintaining a running total height, and not recomputing the same values multiple times in a single pass. These are useful optimizations, but they don't change the asymptotic complexity: lookup still takes &lt;strong&gt;O(n)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a linear scan is a good simple starting implementation and a baseline for comparison. A large list needs a way to find a row without visiting every row before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2. Prefix sums: fast lookup, expensive updates
&lt;/h2&gt;

&lt;p&gt;A row's height is also the distance to the next row. If you add up the heights of all rows before row &lt;code&gt;k&lt;/code&gt;, you get the coordinate of its top edge.&lt;/p&gt;

&lt;p&gt;For example, for heights &lt;code&gt;[40, 60, 30, 70]&lt;/code&gt;, the boundary coordinates are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rows:        [   40   ][     60     ][  30  ][      70      ]
Boundaries:  0        40            100    130             200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's build a prefix sum array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prefix[k]&lt;/code&gt; is the total height of the first &lt;code&gt;k&lt;/code&gt; rows. So you can get the start coordinate of a row in &lt;strong&gt;O(1)&lt;/strong&gt;, and since the boundary coordinates are sorted, you can find a row with binary search in &lt;strong&gt;O(log n)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Say &lt;code&gt;scrollTop = 115&lt;/code&gt;. The largest boundary that doesn't exceed 115 is 100. So the visible area starts in the third row (counting from one), 15 pixels below its top edge.&lt;/p&gt;

&lt;p&gt;But now imagine that, once measured, the first row turns out to be 10 pixels taller than estimated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: [0, 40, 100, 130, 200]
After:  [0, 50, 110, 140, 210]
             └── every later boundary shifted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To update the prefix array, you have to change every value after it. In the worst case, a single measurement costs &lt;strong&gt;O(n)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Chat message heights are refined after rendering and can change again when an image loads or the window is resized. So updates aren't an exception — they're a normal part of the component's work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; prefix sums work well when data rarely changes. A list with frequent measurements needs a structure that updates only the affected sums.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3. Fenwick tree: fast queries and point updates
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://en.wikipedia.org/wiki/Fenwick_tree" rel="noopener noreferrer"&gt;Fenwick tree&lt;/a&gt; doesn't store every prefix sum; it stores a set of partial sums. Each node covers a specific range of elements. To get the sum of the first &lt;code&gt;k&lt;/code&gt; rows, you add up just a few of these ranges.&lt;/p&gt;

&lt;p&gt;For eight rows, the node ranges look like this. Tree indices start at one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node:      1     2      3      4       5      6       7       8
Range:    [1]  [1–2]   [3]   [1–4]    [5]   [5–6]    [7]   [1–8]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, to get the sum of the first seven rows, you only need nodes 7, 6, and 4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;7 → 6 → 4 → 0

[7] + [5–6] + [1–4] = sum of rows [1–7]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of seven additions, you do three. For larger arrays, the number of steps grows logarithmically.&lt;/p&gt;

&lt;p&gt;Why do the jumps work this way? The length of node &lt;code&gt;i&lt;/code&gt;'s range is &lt;code&gt;i &amp;amp; -i&lt;/code&gt; — the value of the lowest set bit of &lt;code&gt;i&lt;/code&gt;. For example, 6 in binary is &lt;code&gt;110&lt;/code&gt;, and its lowest set bit is 2. So node 6 stores the sum of elements 5 and 6.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updating the height of a single row
&lt;/h3&gt;

&lt;p&gt;If row 6's height changes, you only need to update the nodes whose ranges include that row. For eight elements, the update path looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update row 6:
6 → 8 → done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We store the raw heights separately so we can compute the difference between the new and old values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prefixSum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setHeight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextHeight&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;index&lt;/code&gt; is the zero-based row index, while the tree's internal indices start at one. The code assumes &lt;code&gt;tree&lt;/code&gt; has already been built and holds correct partial sums.&lt;/p&gt;

&lt;p&gt;Both the prefix query and the point update take &lt;strong&gt;O(log n)&lt;/strong&gt;. Unlike a prefix sum array, a Fenwick tree doesn't require updating every subsequent prefix sum when a row's height changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding a row by coordinate
&lt;/h3&gt;

&lt;p&gt;Finding the sum of the first &lt;code&gt;k&lt;/code&gt; elements and finding an index for a given sum are different problems. For the second one, you can do a binary lifting search over the Fenwick tree: check powers of two from largest to smallest, and take the next range if its sum fits into the remaining offset.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findByOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prefixSum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;
      &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// position is the number of rows lying entirely before offset.&lt;/span&gt;
  &lt;span class="c1"&gt;// With positive heights, this is the zero-based row index.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This search assumes heights are non-negative, the tree is built correctly, and &lt;code&gt;offset&lt;/code&gt; is given in list coordinates. In a production component, the boundaries need to match your chosen semantics: for example, which row counts as active when the coordinate lands exactly on a row's bottom edge. An empty list and coordinates near the end also need explicit handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where a Fenwick tree stops being convenient
&lt;/h3&gt;

&lt;p&gt;A Fenwick tree is great when heights are updated one at a time. But suppose the app switches display modes and needs to add the same amount to the height of an entire range of rows at once.&lt;/p&gt;

&lt;p&gt;If you perform a hundred thousand individual updates, the total work is &lt;strong&gt;O(n log n)&lt;/strong&gt;. The plain Fenwick tree in this implementation can't apply a single operation to an arbitrary range in O(log n).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if the main workload is coordinate lookups and individual height changes, a Fenwick tree offers a good balance of speed and simplicity. If you need frequent updates to whole ranges, consider a segment tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4. Segment tree: range updates in O(log n)
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://en.wikipedia.org/wiki/Segment_tree" rel="noopener noreferrer"&gt;segment tree&lt;/a&gt; divides an array into ranges. The root represents the whole list, its children represent the left and right halves, and the leaves represent individual rows. Each node stores the total height of its range.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        [1–8]
                 sum of all eight rows
                    /             \
                 [1–4]             [5–8]
                /     \           /     \
             [1–2]   [3–4]      [5–6]   [7–8]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To find the row at a given coordinate, compare the coordinate with the total height of the left subtree. If it's smaller, the target row is on the left. Otherwise, subtract the left half's sum and go right. Each step halves the range, so lookup takes &lt;strong&gt;O(log n)&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lazy updates
&lt;/h3&gt;

&lt;p&gt;Now imagine you need to add 5 pixels to the height of every row in the range &lt;code&gt;[1–8]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A naive approach would update all eight leaves. But the root already represents the entire range. It's enough to increase its sum by &lt;code&gt;8 × 5&lt;/code&gt; and record the addition in a &lt;code&gt;pending&lt;/code&gt; field to propagate later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:                After adding +5:

       [1–8]                  [1–8, pending +5]
      /     \                 /               \
  [1–4]   [5–8]           children not updated yet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This technique is called &lt;a href="https://en.wikipedia.org/wiki/Segment_tree#Lazy_propagation" rel="noopener noreferrer"&gt;lazy propagation&lt;/a&gt;. The node's sum already reflects the change, but its children haven't been updated yet. When the algorithm needs to descend to the children, it first pushes the accumulated addition down to them.&lt;/p&gt;

&lt;p&gt;In simplified form, applying a change looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;
  &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A range update follows three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the node doesn't overlap the target range, skip it.&lt;/li&gt;
&lt;li&gt;If the node lies entirely inside the range, update its sum and pending addition without descending to the leaves.&lt;/li&gt;
&lt;li&gt;If the node is only partially covered, push the pending addition down to its children, update the relevant subranges, and recompute the node's sum.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;middle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nf"&gt;addRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;middle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;addRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;middle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This snippet illustrates the core idea. A full implementation should also include building the tree, bounds checking, handling an empty list, and a &lt;code&gt;push&lt;/code&gt; function that passes the pending change down to the children and resets the current node's &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A key invariant: &lt;code&gt;sum[node]&lt;/code&gt; already accounts for &lt;code&gt;pending[node]&lt;/code&gt;, but the children's sums may not include that addition yet. If you forget to push it down before descending, queries will start returning wrong sums.&lt;/p&gt;

&lt;p&gt;For any range, the tree visits only a bounded number of nodes per level, so adding to a range takes &lt;strong&gt;O(log n)&lt;/strong&gt;. A point height update also takes O(log n).&lt;/p&gt;

&lt;p&gt;There's an important caveat, though: this advantage applies only when a single operation can be applied uniformly across the range — for example, adding the same amount to every row. If resizing the window makes each message wrap differently and end up with its own new height, the tree doesn't spare you from measuring those rows. It speeds up storing and updating the values, but it doesn't eliminate the work needed to obtain them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a segment tree is justified when the app genuinely needs frequent range updates. If only individual rows change, a Fenwick tree is usually simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the structures on the same set of operations
&lt;/h2&gt;

&lt;p&gt;Let's put the properties of these solutions into a single table. Here, "find a row by coordinate" means locating a row at a given coordinate while accounting for heights, and "add a value to a range" means applying the same addition to every row in a range.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Structure&lt;/th&gt;
&lt;th&gt;Find a row by coordinate&lt;/th&gt;
&lt;th&gt;Update a single height&lt;/th&gt;
&lt;th&gt;Add a value to a range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Height array with linear scan&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix sum array&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fenwick tree&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(n log n) via individual updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Segment tree with lazy propagation&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These complexities describe algorithmic work, not guaranteed time in milliseconds. On a real device, results depend on the implementation, available memory, the browser, the number of measurements, and the work performed by the UI itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the measurements show
&lt;/h2&gt;

&lt;p&gt;In the original experiment with 100,000 rows, finding the window near the end of the history took about 50 ms with the naive scan and about 3–4 µs with the indexed structures. Updating the height of a row near the start of the history took about 387 µs with prefix sums and about 2 µs with either the Fenwick tree or the segment tree. Adding a value to the entire range took about 7.6 ms with the Fenwick tree and about 2 µs with the segment tree.&lt;/p&gt;

&lt;p&gt;These are results from a specific microbenchmark, not universal properties of the data structures. Before publishing numbers like these, you should include the benchmark code and specify the runtime environment, the number of iterations, the warm-up method, and how the final values were calculated. Without that information, the numbers are hard to verify independently.&lt;/p&gt;

&lt;p&gt;A separate scrolling run in headless Chrome with a 6× CPU slowdown produced the following results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;p95 frame time&lt;/th&gt;
&lt;th&gt;Frames over 20 ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Naive scan&lt;/td&gt;
&lt;td&gt;248 ms&lt;/td&gt;
&lt;td&gt;28 of 29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Height array&lt;/td&gt;
&lt;td&gt;24 ms&lt;/td&gt;
&lt;td&gt;28 of 312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix sums&lt;/td&gt;
&lt;td&gt;18 ms&lt;/td&gt;
&lt;td&gt;0 of 455&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fenwick tree&lt;/td&gt;
&lt;td&gt;18 ms&lt;/td&gt;
&lt;td&gt;5 of 425&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Segment tree&lt;/td&gt;
&lt;td&gt;18 ms&lt;/td&gt;
&lt;td&gt;6 of 448&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These numbers are also specific to the test environment. They show that switching from a linear search to an indexed data structure significantly improved performance in this run, while switching between indexed structures had little effect on the p95 frame time.&lt;/p&gt;

&lt;p&gt;Why? Once one bottleneck is removed, another part of the UI can become the limiting factor: row measurement, React component updates, layout, or painting. Speeding up the data structure doesn't necessarily speed up the whole frame by the same factor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practical takeaway from the benchmark:&lt;/strong&gt; choose a more complex structure for the operations it actually speeds up. Don't adopt a segment tree just hoping it will automatically make scrolling smoother.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correct sums don't guarantee correct scrolling
&lt;/h2&gt;

&lt;p&gt;Even with a fast height index, a virtual list still has another problem: the height of an off-screen row may be unknown.&lt;/p&gt;

&lt;p&gt;Until a row is rendered, the component uses an estimate. When the row appears, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver" rel="noopener noreferrer"&gt;&lt;code&gt;ResizeObserver&lt;/code&gt;&lt;/a&gt; lets you get its actual size and update the height index.&lt;/p&gt;

&lt;p&gt;But if a row above the viewport becomes 12 pixels taller, the coordinates of every row after it also increase by 12 pixels. Without compensation, the user sees a jump: the message they were reading moves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Height of a row above viewport:  +12 px
Scroll position:                 +12 px
Visible message:                 stays in place
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the height of a row above the anchor grows by &lt;code&gt;delta&lt;/code&gt; and the reading position must be preserved, the scroll position is usually adjusted by the same amount. Keep the sign in mind: if the row got shorter, the adjustment is negative.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preserve an anchor, not just a pixel count
&lt;/h3&gt;

&lt;p&gt;For reliable compensation, it helps to remember the first visible row and the offset within it. After heights change, the component recomputes that row's coordinate and restores the previous offset within it.&lt;/p&gt;

&lt;p&gt;This is more robust than blindly adding every change to &lt;code&gt;scrollTop&lt;/code&gt;: several rows may change during a single cycle, the user may scroll manually, and measurements may arrive in a different order from the one in which they were requested.&lt;/p&gt;

&lt;p&gt;The same principle applies when loading older messages at the top. If rows are prepended before the current history, you need to keep the anchor message in place. First update the total height calculation and the spacer size, and only then restore the scroll position. Otherwise, the new &lt;code&gt;scrollTop&lt;/code&gt; value may exceed the old content height, and the browser will clamp it.&lt;/p&gt;

&lt;p&gt;The browser may also try to preserve the scroll position automatically through scroll anchoring. If your component manages the anchor itself, check how it interacts with the CSS &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor" rel="noopener noreferrer"&gt;&lt;code&gt;overflow-anchor&lt;/code&gt;&lt;/a&gt; property so the browser and the app don't both compensate for the same change.&lt;/p&gt;

&lt;p&gt;There's also a special case: the user is at the bottom of the chat. In that case, you usually want to keep the view anchored to the bottom rather than preserve the position of a specific message — provided that this matches your product's intended behavior. If the user is reading older messages, new messages shouldn't force them back to the bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical sequence of steps
&lt;/h2&gt;

&lt;p&gt;Don't start with the most complex structure. Evolve the solution as real limitations surface.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Implement virtualization.&lt;/strong&gt; Render only the visible rows and a small buffer around them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with a simple height model.&lt;/strong&gt; Verify that coordinates and edge cases are correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure performance.&lt;/strong&gt; Make sure that row lookup or height updates are actually the bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose a structure that fits your update pattern.&lt;/strong&gt; Prefix sums suit data that rarely changes, a Fenwick tree suits frequent point updates, and a segment tree suits frequent range updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solve scroll jumps separately.&lt;/strong&gt; Keep a reading anchor and compensate for height changes correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure again.&lt;/strong&gt; Compare not only the data-structure microbenchmark but also the behavior of the full UI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A good virtual list is more than just a small number of DOM nodes. It computes coordinates quickly, handles variable heights correctly, and keeps the reader's place when content changes.&lt;/p&gt;

&lt;p&gt;The most complex structure isn't always the most useful one. The best solution is the one that removes the measured bottleneck, keeps the UI behaving correctly, and doesn't add unnecessary complexity.&lt;/p&gt;

&lt;p&gt;Code for this article: &lt;a href="https://github.com/godfreyd/virtual-list-fenwick" rel="noopener noreferrer"&gt;godfreyd/virtual-list-fenwick&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From 100 Million Events to 1,200 Pixels: Engineering a Data-Heavy Dashboard</title>
      <dc:creator>Sergey</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:28:34 +0000</pubDate>
      <link>https://dev.to/bocharov/from-100-million-events-to-1200-pixels-engineering-a-data-heavy-dashboard-30ke</link>
      <guid>https://dev.to/bocharov/from-100-million-events-to-1200-pixels-engineering-a-data-heavy-dashboard-30ke</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0hjh51ars5vqb8w4a7c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0hjh51ars5vqb8w4a7c.png" alt="From 100 Million Events to 1,200 Pixels" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your dashboard has a chart that is about 1,200 pixels wide.&lt;/p&gt;

&lt;p&gt;Behind it sits a table that grows by a few million rows a day. Somebody opens the page, picks "last 30 days", and expects to see request volume, cost, error rate and p95 latency — immediately, and again a second later when they drag across a spike to zoom in.&lt;/p&gt;

&lt;p&gt;Somewhere between those two facts — millions of rows on one side, a few hundred visible values on the other — your architecture decides where the data gets smaller. Usually nobody makes that decision on purpose. It happens in the first version of the API, and it stays there until the dashboard gets slow.&lt;/p&gt;

&lt;p&gt;This article is about making that decision deliberately. The question is simple to state:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do we get from a hundred million raw events to the few hundred values the UI actually needs?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the claim I want to back with measurements rather than opinions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Performance of a data-heavy frontend starts long before React.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By the time a component renders, almost every important decision has already been made: what was stored, what was scanned, what was aggregated, what crossed the network, and what the browser had to do with it. Drawing 730 points is easy. Getting to the right 730 points is the actual engineering problem — and, as we'll see, a correctness problem as much as a speed problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case study: an AI SaaS usage dashboard
&lt;/h2&gt;

&lt;p&gt;To keep things concrete, imagine a product built on top of large language models. Every model call produces one event:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2025-06-12T14:03:27.481920Z&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;u_0004211&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;model&lt;/code&gt;, &lt;code&gt;provider&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;atlas-mini&lt;/code&gt;, &lt;code&gt;northwind&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tokens_input&lt;/code&gt;, &lt;code&gt;tokens_output&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;412&lt;/code&gt;, &lt;code&gt;187&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;latency_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1269&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cost_usd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;174&lt;/code&gt; (micro-dollars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;country&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt; or &lt;code&gt;timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The product team wants the usual dashboard: requests over time, cost, tokens, error rate, latency percentiles, breakdowns by model, provider, country and user, filters on all of them, and zooming from a year down to an hour.&lt;/p&gt;

&lt;p&gt;None of this is specific to AI. Replace "model call" with "HTTP request", "payment" or "sensor reading", and you have an observability, fintech or IoT dashboard: a high-volume, append-only stream of events and a UI that needs a compact summary of it. LLM usage is just a convenient example with interesting metrics — money, tokens, heavy-tailed latency — and plenty of dimensions to slice by.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path every number travels
&lt;/h2&gt;

&lt;p&gt;Every value on the chart takes the same route:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrnjhfvgag8v2yuvr2q5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrnjhfvgag8v2yuvr2q5.png" alt="The information-reduction pipeline: raw events, storage, query, aggregation, API, network, browser, chart" width="799" height="175"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each arrow is a place where the data can get smaller — or not. The interesting question is &lt;em&gt;where&lt;/em&gt; the reduction happens, because every step to the left of that point pays for the full volume, and every step to the right only sees the result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The browser doesn't need events. It needs answers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A 30-day chart of p95 latency needs roughly one value per pixel column, maybe with a min/max band. It does not need the thousands of individual requests behind each of those values. The rest of this article follows the data along this path and measures what changes when the reduction moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small lab instead of opinions
&lt;/h2&gt;

&lt;p&gt;Arguments about dashboards and databases tend to be settled by whoever has the most confident anecdote. To avoid that, I built a small, reproducible lab (linked at the end).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One dataset.&lt;/strong&gt; A generator produces a year of fictional LLM events with realistic structure: daily and weekly seasonality, growth, traffic spikes, heavy-tailed latency that differs per model, provider incidents with bursts of errors, and a few heavy users. The same seed always produces the same events, at 100 thousand, 1 million, 10 million and 100 million events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two independent axes.&lt;/strong&gt; Every result is a cell in a matrix: the &lt;strong&gt;engine&lt;/strong&gt; — PostgreSQL, DuckDB or ClickHouse — and the &lt;strong&gt;strategy&lt;/strong&gt;, which says where and how the data is reduced: &lt;code&gt;naive&lt;/code&gt;, &lt;code&gt;server-aggregation&lt;/code&gt;, an &lt;code&gt;optimized&lt;/code&gt; physical layout, pre-aggregated &lt;code&gt;rollup&lt;/code&gt;s, and &lt;code&gt;auto&lt;/code&gt;, a planner that chooses among them. The goal is not to crown the fastest database. It is to see how the &lt;em&gt;placement&lt;/em&gt; of computation changes the whole system, regardless of the engine underneath.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One question, one identity.&lt;/strong&gt; Every request is normalized into a canonical form — an exact time range, a timezone, sorted filters, the bucket size, a result budget — and hashed. When two engines answer "the same question", their responses carry the same hash, so it can be checked that they really answered the same question.&lt;/p&gt;

&lt;p&gt;Measurements were taken on one machine with warm caches: each cell is the median of repeated requests after warm-up (two warm-ups and seven repetitions on the larger datasets), cells run in shuffled order, every comparable response is compressed with gzip at the same fixed level, and browser timings come from a production build in Chromium. Every request has 120 seconds, and every cell has an explicit outcome — completed, timeout, failed — instead of a missing value. Every number is traceable to a recorded run, and the harness rejects any run it cannot prove belongs to the engine, strategy and dataset it claims. The full methodology lives in the repository's README.&lt;/p&gt;




&lt;h2&gt;
  
  
  The browser receives too much
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: &lt;code&gt;naive&lt;/code&gt; — the browser receives raw events and aggregates them in JavaScript.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The dashboard has to show requests, cost and p95 latency over time. The fastest way to get there is to fetch the events and compute the chart in the browser.&lt;/p&gt;

&lt;p&gt;That is not a strawman. It is what most of us build first, for good reasons: the backend already has an endpoint that returns events, JavaScript is perfectly good at grouping by time bucket, the first version ships in an afternoon, and every new chart is a frontend-only change. And the problem never arrives as a failure. It arrives gradually, as a dashboard that was instant in March and takes several seconds by September.&lt;/p&gt;

&lt;p&gt;In the lab the naive path streams events as NDJSON, and the browser parses them, assigns them to buckets and sorts latencies for the percentiles. (One detail worth stealing: the stream ends with a trailer line, because once a streamed response has started, an error can only be reported inside the stream — and a stream without a trailer must be treated as truncated, not as a dip in the chart.)&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;p&gt;Here is the one-year view on the smallest dataset, 100,000 events:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;One-year view, 100k events&lt;/th&gt;
&lt;th&gt;naive&lt;/th&gt;
&lt;th&gt;server-side aggregation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Events sent to the browser&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Values on the chart&lt;/td&gt;
&lt;td&gt;730 buckets&lt;/td&gt;
&lt;td&gt;730 buckets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload, uncompressed&lt;/td&gt;
&lt;td&gt;24.4 MB&lt;/td&gt;
&lt;td&gt;82 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transferred with gzip&lt;/td&gt;
&lt;td&gt;3.36 MB&lt;/td&gt;
&lt;td&gt;15 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total time, request to result&lt;/td&gt;
&lt;td&gt;~1.36 s&lt;/td&gt;
&lt;td&gt;~51 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ueofah1rrd9ti922878.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ueofah1rrd9ti922878.png" alt="Naive vs server-side aggregation for the one-year chart on 100,000 events" width="799" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cost grows with the range the user looks at: a 30-day view moved 7,781 events (266 kB transferred), a single day 288. And in production it grows with every month of history you keep.&lt;/p&gt;

&lt;p&gt;The 1.36 seconds themselves say more about this implementation than about the architecture: a separate profile spent about 0.53 s serializing events in Python and 0.28 s compressing them — work in the API layer, which is the same for every engine. What does not depend on implementation is the volume: to draw 730 points, 100,000 events were read, 24.4 MB produced and 100,000 objects processed in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compression is not reduction
&lt;/h3&gt;

&lt;p&gt;Gzip shrank the yearly payload about seven times. That is a real improvement, and it is tempting to stop there. But the database still returned 100,000 rows, the server still serialized 100,000 objects, the browser still parsed all of them — and the chart still showed 730 values.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Compression reduces the size of the representation; aggregation reduces the volume of information.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A compressed list of 100,000 events still contains 100,000 events.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened as the data grew
&lt;/h3&gt;

&lt;p&gt;In a real browser, the one-year view on 1 million events downloaded 1,000,000 events — 32.7 MB over the wire — and took 15 to 18 seconds to draw. The same chart from server-side aggregation arrived as 15 kB and was on screen in 63 to 495 ms, depending on the engine.&lt;/p&gt;

&lt;p&gt;At 10 million events the naive one-year view did not finish within two minutes on any engine. At 100 million, neither did the one-month view; only the one-day view still completed, by moving 287,370 events (8.9 MB on the wire) to draw 288 points. Every engine failed at the same place, because what grew was the number of events per request — the naive architecture's real unit of cost. The browser was receiving about 137 events for every point it drew, and that ratio only gets worse as history accumulates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move the computation to the data
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: &lt;code&gt;server-aggregation&lt;/code&gt; — the same events, filtered, grouped and aggregated inside the engine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second strategy changes a single thing: the question travels to the data, instead of the data travelling to the question.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /v1/timeseries?from=…&amp;amp;to=…&amp;amp;metrics=requests,cost,p95_latency&amp;amp;max_points=1200
        ▼
canonical query: range · timezone · bucket size · budget  →  hash
        ▼
engine: filter → group into buckets → count / sum / percentile
        ▼
API → 730 buckets of JSON → browser → chart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. The client sends &lt;code&gt;max_points&lt;/code&gt; equal to the chart's width in device pixels, and the server picks a bucket size so that the result never has more buckets than that. And the request is no longer "give me rows" but "give me these metrics, at this resolution, for this range, with these filters" — the API stops being a CRUD endpoint and starts behaving like a small query language.&lt;/p&gt;

&lt;p&gt;One shared layer defines &lt;em&gt;what&lt;/em&gt; a bucket, a percentile or a filter means; a thin adapter per engine decides &lt;em&gt;how&lt;/em&gt; to compute it — &lt;code&gt;percentile_cont()&lt;/code&gt; in PostgreSQL, &lt;code&gt;quantile_cont()&lt;/code&gt; in DuckDB, &lt;code&gt;quantileExactInclusive()&lt;/code&gt; in ClickHouse. For every control query, all three engines returned the same canonical hash and equivalent values. The reduction strategy is independent of the engine.&lt;/p&gt;

&lt;p&gt;The first measurements showed DuckDB slower than PostgreSQL on small queries. Before writing that down, I traced it: the engine ran the query in 3–4 ms, and about 40 ms went into passing a few hundred bucket boundaries from Python as a query parameter — a property of my adapter, not of the database. Passing them as a small in-memory table took the overhead from about 48 ms to about 7 ms. The measuring apparatus has to be cheaper than the thing it measures.&lt;/p&gt;

&lt;p&gt;Server-side aggregation shrank the yearly answer from 100,000 events to 730 buckets — roughly 300 times less data, more than 200 times less on the wire — while the chart stayed exactly the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sidebar: cancelling work nobody needs anymore
&lt;/h2&gt;

&lt;p&gt;Users don't wait for a chart before changing their mind. They drag to zoom, zoom again, flip a filter, and every new action makes the previous query useless. An &lt;code&gt;AbortController&lt;/code&gt; per request stops the &lt;em&gt;browser&lt;/em&gt; from waiting — but unless the cancellation travels all the way down, from the closed HTTP connection through the application into the engine, the database keeps computing answers nobody will see.&lt;/p&gt;

&lt;p&gt;The lab simulates a user who changes zoom and filters four times in quick succession while each query is kept busy in the engine for 1.5 seconds. On all three engines the three superseded requests were cancelled, exactly one query was running while the latest one executed, and no orphaned queries were left afterwards. A frontend concern — "zooming feels sluggish" — was solved by a change that reaches into query execution. The most expensive query is the one whose result is thrown away.&lt;/p&gt;




&lt;h2&gt;
  
  
  Correctness before performance
&lt;/h2&gt;

&lt;p&gt;A fast dashboard that shows the wrong p95 is worse than a slow one, because nobody notices. So before any benchmark, every engine and strategy has to produce the same answer — and that answer has to be right.&lt;/p&gt;

&lt;p&gt;If PostgreSQL and DuckDB return the same number, that proves they agree, not that the number is right. Bucketing, timezones and the definition of a percentile live in shared code; a bug there makes every engine wrong in the same way. So the lab has three independent layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A hand-written fixture&lt;/strong&gt; of 57 events, each placed to test something: an event exactly at the start of the range and one exactly at its end, a 23-hour Berlin day, a Kolkata day that starts at 18:30 UTC, empty hours, a latency spike, a range that runs past the data. The expected answers were computed by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An independent reference implementation&lt;/strong&gt; — plain Python loops that share no code with the query layer and assign events to buckets with a different algorithm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same control queries against every engine and strategy&lt;/strong&gt;, compared strictly: integers exactly, floating-point values within a relative error of 10⁻⁹.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The suite paid for itself on its first real run. On the 100k dataset, the engines disagreed with the reference on weekly buckets in UTC — and the bug was in the &lt;em&gt;reference&lt;/em&gt;. It started UTC weeks on Thursday, because 1 January 1970 was a Thursday, while the specification starts weeks on Monday. Even an independent oracle can be wrong; that is why the hand-computed fixture exists as the one check that trusts no code at all. After the fix, every control query passed on every engine and strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  A preview: what you keep decides what you can answer
&lt;/h3&gt;

&lt;p&gt;Correctness also decides what can be pre-aggregated later. One day in the fixture has 36 requests: one hour with 20 slow requests (1,000–1,950 ms), eight hours with two fast ones each. Suppose only hourly summaries were kept:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How the daily value is computed&lt;/th&gt;
&lt;th&gt;Average latency&lt;/th&gt;
&lt;th&gt;p95 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exact, from all 36 events&lt;/td&gt;
&lt;td&gt;868.3 ms&lt;/td&gt;
&lt;td&gt;1,862.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average of the hourly values&lt;/td&gt;
&lt;td&gt;261.7 ms&lt;/td&gt;
&lt;td&gt;317.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum of the hourly values&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,902.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;From composable state: hourly &lt;code&gt;sum&lt;/code&gt; + &lt;code&gt;count&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;868.3 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;From composable state: merged hourly histograms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,852.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdh36uh3zuuldw0yvp1v3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdh36uh3zuuldw0yvp1v3.png" alt="Which hourly summaries can be combined into the daily value: final averages and p95s cannot, sum + count and merged histograms can" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The average of averages is off by a factor of three, because it forgets that the slow hour had ten times the traffic; keeping &lt;code&gt;sum&lt;/code&gt; and &lt;code&gt;count&lt;/code&gt; makes it exact again. Neither the average nor the maximum of hourly p95 values gives the daily p95. Merged hourly histograms gave 1,852.4 ms against the exact 1,862.5 ms — an observation on 36 hand-placed events, not a guarantee, but an approximation of the right thing instead of an exact answer to the wrong question.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pre-aggregation is only correct if we preserve enough information to compose the metric later.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Sidebar: benchmarking code needs correctness checks too
&lt;/h3&gt;

&lt;p&gt;The measuring code needed the same suspicion as the code it measures. Each of these would have produced a confident, wrong number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the cancellation test passed for ClickHouse while proving nothing — the artificial "busy" query was optimized away, so nothing was ever in flight;&lt;/li&gt;
&lt;li&gt;the harness silently dropped a parameter and measured the baseline strategy in every cell labelled "optimized";&lt;/li&gt;
&lt;li&gt;an engine-side time limit stricter than the benchmark's own turned a slow stream into an "error" on one engine and a "timeout" on the others;&lt;/li&gt;
&lt;li&gt;the first PostgreSQL rollups measured &lt;em&gt;slower&lt;/em&gt; than the raw events they summarize, because my builder wrote them in hash order, so reading one month of a time-indexed summary touched nearly the whole table;&lt;/li&gt;
&lt;li&gt;an exact percentile on ClickHouse that had "run out of memory" turned out to hold only 427 MiB of the 2.70 GiB the server reported in use when it was stopped. The failure was not attributable to the query's own memory, and in a clean state the same query passed every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them showed up as a crash; each showed up as a number. The harness now fails closed: every measurement must prove, from what the server reports, which engine, strategy, dataset and canonical question it belongs to, or the run is discarded — and discarded runs stay on record with the reason. A database benchmark can measure work you did not realize was running.&lt;/p&gt;




&lt;h2&gt;
  
  
  The database is still reading too much
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: &lt;code&gt;optimized&lt;/code&gt; — the same events, in a physical layout each engine can prune.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Server-side aggregation fixed the network. It did not fix the work: the engine still computed every chart from event-level data, and on 100 million events ClickHouse reported reading all 100 million rows to draw a single day.&lt;/p&gt;

&lt;p&gt;The OLTP reflex is to index the timestamp. But an index helps you find a few rows; a dashboard wants millions of rows, grouped. What matters at this scale is whether the engine can discard whole partitions, files or blocks before reading them. So each engine got a layout built for pruning: PostgreSQL monthly partitions with a BRIN index on time; DuckDB Parquet partitioned by day and sorted by model and time; ClickHouse monthly partitions sorted by model, country and time. The correctness suite re-checked every query on every layout.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;100M events, baseline → pruning layout&lt;/th&gt;
&lt;th&gt;One day&lt;/th&gt;
&lt;th&gt;One month&lt;/th&gt;
&lt;th&gt;One year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;145 → 127 ms&lt;/td&gt;
&lt;td&gt;4.4 → 3.6 s&lt;/td&gt;
&lt;td&gt;51 → 52 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB&lt;/td&gt;
&lt;td&gt;109 → 78 ms&lt;/td&gt;
&lt;td&gt;0.92 → 1.22 s&lt;/td&gt;
&lt;td&gt;26 → 27 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse&lt;/td&gt;
&lt;td&gt;491 → 51 ms&lt;/td&gt;
&lt;td&gt;533 → 178 ms&lt;/td&gt;
&lt;td&gt;1.2 → 1.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ClickHouse is the clearest example of pruning doing exactly what it promises: for one day it read 2.74 million rows instead of 100 million. The other two engines are more interesting. PostgreSQL barely changed — consistent with its baseline already having a B-tree on the timestamp, which serves exactly this kind of time-range question. DuckDB's baseline was the generator's time-ordered Parquet, whose per-row-group statistics allow skipping much of a file for a narrow range. Its day-partitioned layout was &lt;em&gt;slower&lt;/em&gt; than that baseline everywhere except the one-day view at 100 million events — at 10 million, a day took 36 ms instead of 18. That is consistent with the cost of opening and planning thousands of small files outweighing the rows they let it skip; the lab did not profile where the time went.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpjcwmniu94amqqbkw0o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpjcwmniu94amqqbkw0o.png" alt="ClickHouse rows read for one chart: 100 million for a day on the baseline layout, 2.74 million with pruning, all 100 million for a year" width="799" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And nothing helped the year. A year is the whole dataset: there is nothing to skip.&lt;/p&gt;

&lt;p&gt;Layouts also have a price. On 100 million events, PostgreSQL's partitioned table took 16.7 GB instead of 14.0 GB and 390 s to build; ClickHouse's took 1.6 GB instead of 3.6 GB, its compressed column types more than paying for the extra structure; DuckDB's sorted, partitioned Parquet took 1.7 GB instead of 2.1 GB.&lt;/p&gt;

&lt;p&gt;A physical layout is a bet on which questions will be cheap. Pruning reduces what the engine reads only when the question is narrower than the data. The widest question a dashboard asks — "show me the whole year" — reads everything, every time, and recomputes history that will never change.&lt;/p&gt;

&lt;p&gt;The obvious move is to compute those answers once and keep them. But before storing anything, we have to know what a stored answer must contain — and that depends on the metric.&lt;/p&gt;




&lt;h2&gt;
  
  
  How expensive is a percentile?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: one layout, one range at a time, and only the metric changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A dashboard tile looks like a number. Behind it is an aggregation, and aggregations are not equally expensive. A count needs one integer per bucket, an average two. An exact percentile needs far more value-level state and work: its answer depends on how all the values in a bucket are distributed, not on a few running totals. Before deciding what to pre-aggregate, I measured what each costs on its own. "Approximate p95" here means a histogram over 142 fixed logarithmic latency bins — the same on every engine, chosen because it can be merged, not because it is fast.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;One month (7.8M events), 100M dataset&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;th&gt;sum + avg&lt;/th&gt;
&lt;th&gt;exact p95&lt;/th&gt;
&lt;th&gt;approximate p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;1.43 s&lt;/td&gt;
&lt;td&gt;1.61 s&lt;/td&gt;
&lt;td&gt;4.12 s&lt;/td&gt;
&lt;td&gt;4.78 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB&lt;/td&gt;
&lt;td&gt;678 ms&lt;/td&gt;
&lt;td&gt;687 ms&lt;/td&gt;
&lt;td&gt;670 ms&lt;/td&gt;
&lt;td&gt;2.39 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse&lt;/td&gt;
&lt;td&gt;106 ms&lt;/td&gt;
&lt;td&gt;162 ms&lt;/td&gt;
&lt;td&gt;176 ms&lt;/td&gt;
&lt;td&gt;1.98 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The exact percentile costs memory before it costs time.&lt;/strong&gt; ClickHouse, the only engine here that reports per-query memory, needed 54 kB to count a year of events and 592 MB for the exact p95 over the same 100 million events, at about the same speed. A count's state does not grow with the data; an exact percentile's does. On PostgreSQL the exact percentile was also about three times slower than the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The approximate percentile was not cheaper.&lt;/strong&gt; Computed from raw events, it was the slowest metric on every engine — eleven times slower than the exact one on ClickHouse for a month. In this lab's implementation each event is assigned to a bin by a generic expression, and ClickHouse reads the data twice; the measured cost is consistent with that extra work, but the lab did not profile it, and a different implementation could behave differently. The measurement does not show that histograms are inherently slow. What the histogram bought was bounded state — 288 MB on ClickHouse for a year, independent of how many events fall into a bucket — and, above all, the ability to merge.&lt;/p&gt;

&lt;p&gt;On PostgreSQL the yearly comparison was not interpretable: a full-year scan of a 16.7 GB table under the lab's 3 GB memory limit reads from disk, varied between 25 and 59 seconds within one series, and hit the two-minute limit in three of four cells at least once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Approximation is an information and representation trade-off, not a performance guarantee.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The percentile is the metric that decides what a summary must contain. An exact percentile needs value-level detail that grows with the data — much closer to the raw events than to a summary — so a summary either keeps that detail or keeps a bounded, mergeable approximation and accepts an error. If we want to stop recomputing history, we first need a representation that can be merged without changing the meaning of the metric. Additive metrics already have one; for the percentile, the histogram is it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why recompute history?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: &lt;code&gt;rollup&lt;/code&gt; and &lt;code&gt;auto&lt;/code&gt; — the same questions, answered from pre-aggregated, composable state instead of events.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Physical layout helped when the query could skip data. A year cannot be skipped. Every time someone opened the yearly view, the engine read 100 million events again and recomputed answers for months that had not changed since the last time — and never would.&lt;/p&gt;

&lt;p&gt;What if we stop asking 100 million events the same question?&lt;/p&gt;

&lt;h3&gt;
  
  
  What we decided to keep
&lt;/h3&gt;

&lt;p&gt;The correctness preview already said what &lt;em&gt;not&lt;/em&gt; to store: no averages, no percentiles — only state that can be combined. The lab keeps three grains, identical on all three engines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rollup&lt;/th&gt;
&lt;th&gt;Time grain&lt;/th&gt;
&lt;th&gt;Dimensions kept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;minute&lt;/td&gt;
&lt;td&gt;model, status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hour&lt;/td&gt;
&lt;td&gt;model, country, status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;day&lt;/td&gt;
&lt;td&gt;model, country, status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every cell stores a count, cost and token sums, a latency sum, a failure count, the latency minimum and maximum, and a 142-bin latency histogram. The daily rollup is built by merging hourly cells, which makes it a composability test in its own right: its per-model, per-day totals matched the generator's records exactly on every engine. One thing is deliberately missing — &lt;code&gt;user_id&lt;/code&gt; — and the next part is about why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asking the summary — and being refused
&lt;/h3&gt;

&lt;p&gt;A planner splits every requested bucket into the largest whole rollup cells that fit it and keep every filtered dimension, and computes whatever is left — ragged edges that don't line up with a minute — from event-level data. A filter on country cannot use the minute grain, which doesn't keep country; a Kolkata day, which starts at 18:30 UTC, is assembled from hourly cells plus thirty minutes of minute cells. An &lt;em&gt;exact&lt;/em&gt; percentile cannot come from a rollup at all, so that combination is reported as not applicable rather than quietly approximated. And when no rollup can answer — a filter on one user — &lt;code&gt;rollup&lt;/code&gt; refuses, while &lt;code&gt;auto&lt;/code&gt; answers from event-level data and says so in its plan. Nothing falls back silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it still the same answer?
&lt;/h3&gt;

&lt;p&gt;Before measuring speed, rollup answers were compared with the independent reference. Additive metrics — requests, cost, tokens, average latency as sum over count, error rate — matched exactly on all three engines, on every control query, from 100 thousand to 100 million events.&lt;/p&gt;

&lt;p&gt;The p95 is now an estimate, and its quality depends on how many events fall into a bucket. On 100 million events the histogram stayed within 0.46% of the exact p95 for daily and six-hour buckets; for five-minute buckets of one model or one country the median error was 1.5–2.2% and the worst bucket was off by 36%. On the 1-million-event dataset, one bucket in ten was off by more than 60%. A histogram describes a crowd well and a handful of requests badly. These are observations on this dataset, not a guarantee — and a dashboard that shows an approximate p95 should say so.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the measurements showed
&lt;/h3&gt;

&lt;p&gt;The yearly view changed most, because that is where recomputation was most wasteful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Yearly view, 100M events&lt;/th&gt;
&lt;th&gt;From events (exact p95)&lt;/th&gt;
&lt;th&gt;From events (approximate p95)&lt;/th&gt;
&lt;th&gt;From rollups (approximate p95)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;45.3 s&lt;/td&gt;
&lt;td&gt;timed out&lt;/td&gt;
&lt;td&gt;10.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB&lt;/td&gt;
&lt;td&gt;41.5 s&lt;/td&gt;
&lt;td&gt;91.6 s&lt;/td&gt;
&lt;td&gt;1.74 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse&lt;/td&gt;
&lt;td&gt;1.69 s&lt;/td&gt;
&lt;td&gt;19.8 s&lt;/td&gt;
&lt;td&gt;0.36 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one-month view went from 3.9 s to 0.91 s on PostgreSQL, 0.65 s to 0.18 s on DuckDB and 164 ms to 117 ms on ClickHouse; the one-day view barely changed anywhere, because pruning had already removed nearly all its work. Pre-aggregation pays where the question is wider than the data you can skip.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffr68uzexyh4w8t4hf2hl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffr68uzexyh4w8t4hf2hl.png" alt="What the engine reads from rollups for the one-year chart: 100 million events, 37.5 million rollup rows, 730 points" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The funnel explains both the gain and its limit. The 730-point yearly chart uses 12-hour buckets, which daily cells cannot fill, so the engine read the hourly rollup: 37.5 million rows instead of 100 million events. And 94% of those rows were histogram rows. Additive state is cheap; the percentile is most of the rollup.&lt;/p&gt;

&lt;p&gt;That is not waste — it is the price of a decision made earlier. Correctness required composable state; a composable percentile requires a histogram per cell; and the histogram is what keeps both the summary large and the p95 answerable for any range. A rollup is not "a few hundred rows".&lt;/p&gt;

&lt;p&gt;Rollups also have a price in storage and build time. Built from scratch on 100 million events they took between 219 s (ClickHouse) and 622 s (PostgreSQL). They occupied 367 MB as Parquet and 609 MB in ClickHouse, but 9.8 GB in PostgreSQL — 70% of the size of its event table; the lab measured that difference but did not isolate its causes. Most of it is the minute grain, which keeps 0.71 rows for every event: a "summary" barely smaller than the data it summarizes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pre-aggregation moves work from every read to one build — and only for the questions its grain can still answer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A rollup behaves like a cache with a schema: it precomputes some questions and deliberately gives up others. It made the most expensive question in the dashboard 4 to 24 times cheaper on every engine, kept additive answers exact, and turned the p95 into an estimate whose quality depends on bucket size. Keeping it fresh as new events arrive is outside this investigation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reducing data means reducing optionality
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: rollup grain, high-cardinality dimensions, and what happens when a summary cannot answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rollups have no &lt;code&gt;user_id&lt;/code&gt;. That was the price of making them small, and it means a support engineer who asks for one customer's latency over the year gets a refusal from &lt;code&gt;rollup&lt;/code&gt; and an event-level query from &lt;code&gt;auto&lt;/code&gt;. Every dimension left out of a summary is a question you have decided to answer the slow way — or not at all.&lt;/p&gt;

&lt;p&gt;So how much would it cost to keep it? The lab counted, exactly, the cells and histogram rows that existing and hypothetical grains would contain on 100 million events. Nothing hypothetical was built; the sizes are counted over the raw events, not estimated.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grain (100M events)&lt;/th&gt;
&lt;th&gt;Cells&lt;/th&gt;
&lt;th&gt;Rows stored per event&lt;/th&gt;
&lt;th&gt;Cannot answer a filter on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1d(model, country, status)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;171,363&lt;/td&gt;
&lt;td&gt;0.037&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1d(model, country, status, user_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;29,158,129&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1d(user_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10,913,108&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;model, country, status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1h(model, country, status)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,266,643&lt;/td&gt;
&lt;td&gt;0.38&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1h(model, country, status, user_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;53,677,021&lt;/td&gt;
&lt;td&gt;1.42&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1m(model, status)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5,433,407&lt;/td&gt;
&lt;td&gt;0.71&lt;/td&gt;
&lt;td&gt;country, user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r_1m(model, country, status)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;46,999,692&lt;/td&gt;
&lt;td&gt;1.42&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adding &lt;code&gt;user_id&lt;/code&gt; to the daily rollup multiplies its cells by 170 and brings it to 0.87 stored rows per event. Adding it to the hourly rollup, or adding country to the minute rollup, produces &lt;em&gt;more&lt;/em&gt; rows than there are events: the "summary" is larger than the data it summarizes. A user-only rollup is smaller, but answers nothing about models or countries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahehdcw35hc4wh0c22ow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahehdcw35hc4wh0c22ow.png" alt="Stored rows per raw event by rollup grain: adding user_id or country pushes the summary past one row per event" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The planner makes the trade-off visible instead of hiding it: a query filtered by user is refused by &lt;code&gt;rollup&lt;/code&gt; and answered by &lt;code&gt;auto&lt;/code&gt; from event-level data, with the reason in its plan — "r_1d cannot be used: it has no user_id dimension".&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every rollup is a decision about which questions remain answerable.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A rollup is not small because it is a rollup. It is small only while its grain and dimensions collapse enough of the original event space — and pre-aggregation reduces data partly by reducing the set of future questions the representation can answer. High-cardinality dimensions belong to a different access path, and the system has to know which path answers which question, and say so. A summary that silently answers a question it cannot answer is worse than one that refuses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your chart has only so many pixels
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: a point budget instead of a bucket size, and a min/max envelope that is part of the query, not a trick in the client.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So far the question was what to store and what to read. The last reduction happens at the other end of the pipeline: how much of the answer to send. Every request so far carried &lt;code&gt;max_points=1200&lt;/code&gt;, and the server turned it into a bucket size. That choice was never tested. A reasonable engineer could argue the opposite: return as much resolution as you can afford and let the chart sort it out — more points can only be more faithful. So: what does extra resolution cost when the screen cannot show it, and what does a coarse answer lose?&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "return everything you have" isn't free
&lt;/h3&gt;

&lt;p&gt;The experiment fixed everything — 100 million events, the yearly range, request rate as the metric, the &lt;code&gt;auto&lt;/code&gt; strategy, ClickHouse and DuckDB — and changed only the point budget. The server picks the finest bucket from a fixed set that fits it, so budgets form a staircase:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Point budget&lt;/th&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;th&gt;Transferred (gzip)&lt;/th&gt;
&lt;th&gt;Time to chart, ClickHouse / DuckDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 or 300&lt;/td&gt;
&lt;td&gt;1 week&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;2 kB&lt;/td&gt;
&lt;td&gt;98–164 / 192–203 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;td&gt;365&lt;/td&gt;
&lt;td&gt;5 kB&lt;/td&gt;
&lt;td&gt;110 / 173 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;12 hours&lt;/td&gt;
&lt;td&gt;730&lt;/td&gt;
&lt;td&gt;8 kB&lt;/td&gt;
&lt;td&gt;292 / 242 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;3 hours&lt;/td&gt;
&lt;td&gt;2,920&lt;/td&gt;
&lt;td&gt;50 kB&lt;/td&gt;
&lt;td&gt;382 / 289 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;8,760&lt;/td&gt;
&lt;td&gt;127 kB&lt;/td&gt;
&lt;td&gt;633 / 457 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twelve times as many points as the 1,200-point chart multiplied the bytes by sixteen and roughly doubled the time to chart on both engines. Drawing was not the bottleneck — the chart rendered 8,760 points about as fast as 730. The cost was everything before the canvas: finer cells to read, more JSON to build, transfer and parse. The budget even decided which summary could be used at all — up to one-day buckets the daily rollup answered; below that, the hourly one, with thirteen times as many cells.&lt;/p&gt;

&lt;p&gt;Before measuring, the lab checked every bucket of every response against per-minute rates computed directly from the raw files: buckets tile the year without gaps or overlaps, never exceed the budget, use the finest step that fits — including a Berlin year with its 23- and 25-hour days — and every value matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a coarse answer loses
&lt;/h3&gt;

&lt;p&gt;Fewer points mean fewer bytes; that part is obvious. The real question is what disappears. To avoid picking a flattering example after the fact, a rule fixed before measurement selected the features: the three highest per-minute traffic peaks relative to their 12-hour rolling median that stay above 1.5× for at most three hours. It picked three spikes, 5.6 to 7.1 times the surrounding rate, lasting one to two and a half hours.&lt;/p&gt;

&lt;p&gt;On the 1,200-point chart each spike falls into one 12-hour bucket, and the bucket's mean showed only 20–23% of the spike's peak; compared with its neighbours, the spike's bucket stood out in only one of three cases. Even at 10,000 points — eight times the screen — the mean reached only 65–81% of the peak. Averaging is a reduction that decides, silently, that short events don't matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the spike without keeping every sample
&lt;/h3&gt;

&lt;p&gt;The alternative is to decide explicitly what must survive. With an envelope, the server also returns for each bucket the minimum and maximum of the &lt;em&gt;per-minute&lt;/em&gt; rate inside it — computed from whole minutes of the minute rollup, and counted against the result budget as two more numbers per point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycdgjnqgawe5bd598as9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycdgjnqgawe5bd598as9.png" alt="Reference per-minute rate, the 12-hour bucket mean and its min/max envelope around a pre-selected spike" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 1,200 points the envelope's maximum equalled each spike's peak per-minute rate exactly, and the spike's bucket stood out from its neighbours by a factor of 3.2 to 6.1. Compared with simply asking for more points, it was the better trade on this workload: 11 kB instead of 127 kB, and the full per-minute peak instead of 65–81% of it, at a similar time to chart. Its cost does not depend on the budget but on the base resolution: every envelope request read the year's 5.4 million minute cells, adding 150 to 310 ms on the server.&lt;/p&gt;

&lt;p&gt;It has limits. It is exact only at its base resolution: it reports the busiest &lt;em&gt;minute&lt;/em&gt;, so a burst shorter than a minute is averaged into it and no individual event is kept. It preserves magnitude, not timing: the spike is only known to lie somewhere in its 12-hour bucket. And it does not guarantee visibility: in weekly buckets, one spike — on a Saturday morning, when traffic is low — was smaller than the ordinary weekday peaks sharing its bucket, so its envelope looked like any other week's.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reducing information is useful only when we know which information the consumer can no longer use — and which information it still needs to see.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A 1,200-pixel chart cannot use 8,760 points. What it could use was a little more information per pixel. Resolution is a budget with two dimensions: the point budget limits temporal resolution — how many buckets — and a value budget limits representation richness — how much each bucket carries. The same 730 buckets can carry one number each or three, and only the second version shows the spike.&lt;/p&gt;




&lt;h2&gt;
  
  
  One fast query is not a production dashboard
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;In the lab: closed-loop concurrency — the same yearly chart from events, from rollups, and from rollups with the envelope, while more users ask at once.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every number so far came from one request at a time. A dashboard is not used that way, and a strategy that is fast alone can still collapse when its requests compete for the same cores. Does reducing information per request also improve the system's ability to serve concurrent users — and what happens to the envelope's extra work when many people want it at once?&lt;/p&gt;

&lt;p&gt;The lab ran a closed loop: virtual users, each sending the next request as soon as the previous answer arrives. Every user asked for the yearly chart at the 1,200-point budget in three representations — &lt;strong&gt;A&lt;/strong&gt;, event-level aggregation on the pruning layout; &lt;strong&gt;B&lt;/strong&gt;, from rollups; &lt;strong&gt;C&lt;/strong&gt;, from rollups with the per-minute envelope — on ClickHouse and DuckDB, with the same container limits as everywhere else and no cache. Users went up 1, 4, 8, 16, 32, and a rule fixed beforehand stopped each ladder when throughput no longer grew while tail latency rose, or when more than 5% of requests failed. CPU per request came from the containers' own accounting, engine and API together.&lt;/p&gt;

&lt;p&gt;The first attempt was discarded. Four event-level DuckDB queries timed out, and two of them kept running for about fifty seconds after their clients had left, competing with the next measurement for the same six cores. Cancellation itself worked — a single heavy query stopped within half a second — but four interrupted heavy queries on one DuckDB instance wound down one at a time. The repeated run waited for every engine to be idle before each step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where throughput stopped
&lt;/h3&gt;

&lt;p&gt;Every ladder stopped early, at 4 or 8 users. Each one except DuckDB's event-level ladder stopped when its engine had used up its CPU allowance — ClickHouse all 3 engine cores, DuckDB all 4; the event-level DuckDB ladder failed by timeouts first, at four users, with requests taking over 100 seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Per representation&lt;/th&gt;
&lt;th&gt;A · events&lt;/th&gt;
&lt;th&gt;B · rollups&lt;/th&gt;
&lt;th&gt;C · rollups + envelope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse: max throughput&lt;/td&gt;
&lt;td&gt;0.95 req/s&lt;/td&gt;
&lt;td&gt;45 req/s&lt;/td&gt;
&lt;td&gt;10 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse: CPU per answer&lt;/td&gt;
&lt;td&gt;3.2–3.6 s&lt;/td&gt;
&lt;td&gt;0.07–0.08 s&lt;/td&gt;
&lt;td&gt;0.26–0.36 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB: max throughput&lt;/td&gt;
&lt;td&gt;0.06 req/s&lt;/td&gt;
&lt;td&gt;11 req/s&lt;/td&gt;
&lt;td&gt;4.8 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB: CPU per answer&lt;/td&gt;
&lt;td&gt;39 s&lt;/td&gt;
&lt;td&gt;0.35–0.37 s&lt;/td&gt;
&lt;td&gt;0.80–0.83 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6pifx5ubd2gxecmacak.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6pifx5ubd2gxecmacak.png" alt="Throughput as users are added: answers from events, from rollups, and from rollups with the envelope, on ClickHouse and DuckDB" width="799" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ceilings matched, within a few percent, the CPU allowance divided by the CPU each answer cost — for ClickHouse from rollups, 3.8 cores ÷ 0.084 s ≈ 45 requests per second. Answering from rollups did not just make one request faster; it raised capacity by about the same factor as it reduced work: 48 times on ClickHouse, about 190 times on DuckDB.&lt;/p&gt;

&lt;p&gt;Past the plateau, adding users bought only waiting: on ClickHouse with rollups the median answer took 32 ms for one user, 86 ms for four and 175 ms for eight, at the same 45 requests per second. The waiting was inside the engine, not in the API's queue. And the engines got there differently: one ClickHouse rollup answer used about 1.6 of its 3 cores, leaving room for a few more users, while one DuckDB answer already kept nearly all 4 cores busy, so its throughput was flat from the first step.&lt;/p&gt;

&lt;h3&gt;
  
  
  The price of the envelope, multiplied
&lt;/h3&gt;

&lt;p&gt;For one user the envelope cost little: on ClickHouse the median answer went from 32 to 97 ms. Under load, that difference became capacity. Reading a year of minute cells made each answer cost about four times as much CPU, and the system served 10 requests per second instead of 45; on DuckDB, 4.8 instead of 11. The envelope's fidelity is still worth having — but it is paid on every request, and under simultaneous demand it is paid in users.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Capacity is a budget of work. Reduce the work per answer before trying to serve more answers at once.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On this machine, how many users a representation could serve followed from how much work each answer took. The strategy that won for one request also won under load, by a wider margin than its latency alone suggested. These are relative results on a small, fixed allowance of cores; they say nothing about how many users any engine supports in general.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results: how the bottleneck moved
&lt;/h2&gt;

&lt;p&gt;Each part of this investigation removed one kind of unnecessary information, and each time the bottleneck moved. Followed in order, the same one-year chart went through six transitions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the data got smaller&lt;/th&gt;
&lt;th&gt;What the yearly chart processed&lt;/th&gt;
&lt;th&gt;What it cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nowhere — the browser aggregates&lt;/td&gt;
&lt;td&gt;every event in the range, sent to the browser&lt;/td&gt;
&lt;td&gt;stopped finishing at 10M events; at 1M, 32.7 MB and 15–18 s to chart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In the engine&lt;/td&gt;
&lt;td&gt;all 100M events read, 730 buckets returned (about 15 kB)&lt;/td&gt;
&lt;td&gt;ClickHouse 1.2 s, DuckDB about 26 s, PostgreSQL about 51 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In the physical layout&lt;/td&gt;
&lt;td&gt;for one day, 2.74M rows read instead of 100M&lt;/td&gt;
&lt;td&gt;51 ms instead of 491 ms on ClickHouse — and no help for the year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In stored history&lt;/td&gt;
&lt;td&gt;37.5M rollup rows instead of 100M events, 94% of them histogram rows&lt;/td&gt;
&lt;td&gt;ClickHouse 0.36 s, DuckDB 1.7 s, PostgreSQL 10.9 s — instead of 1.7 s, 41 s and 45 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;At the screen&lt;/td&gt;
&lt;td&gt;730 points (8 kB) instead of 8,760 (127 kB); 2,190 values with an envelope (11 kB)&lt;/td&gt;
&lt;td&gt;about half the time to chart, with every spike's per-minute peak kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Across users&lt;/td&gt;
&lt;td&gt;45 rollup answers per second instead of 0.95 event-level (ClickHouse); 11 instead of 0.06 (DuckDB)&lt;/td&gt;
&lt;td&gt;capacity set by CPU per answer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read top to bottom, the table is a list of bottlenecks that each looked like &lt;em&gt;the&lt;/em&gt; problem until it was removed: first the network and the browser, then the engine scanning everything, then the engine recomputing history, then the size of what history had to keep to stay correct, then how much of the answer the screen could use, and finally the CPU every answer costs when many people ask at once.&lt;/p&gt;

&lt;p&gt;The reductions were cumulative — rollups mattered because the browser no longer received events, and budgets mattered because rollups had made answers cheap enough for their size to show. And each was safe only because something checked it: the same canonical question, answered identically by three engines and an independent reference, before any of its speed was measured.&lt;/p&gt;

&lt;p&gt;The lab stopped at 100 million events. The trend a larger run would test — event-level work growing with the data, summaries growing far more slowly — was already visible from 100 thousand upward; another step would have confirmed it rather than raised a new question. Not tested at all: keeping rollups fresh, a cache in front of the engine, behaviour beyond capacity under an open arrival rate, and concurrency on PostgreSQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing an architecture
&lt;/h2&gt;

&lt;p&gt;The reader should not take a database ranking from this lab, because none was measured in a way that would support one. What the experiments do support is a sequence of questions about the &lt;em&gt;workload&lt;/em&gt; — each changed the outcome more than the choice of engine did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are you shipping event-level data to answer aggregate questions?&lt;/strong&gt; Then the database is not your first problem. The naive dashboard failed at the same point on all three engines, because its unit of cost was events per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are your queries selective enough for the layout to matter?&lt;/strong&gt; For narrow ranges, yes — but check what the baseline already does. PostgreSQL's ordinary time index already captured most of the benefit; ClickHouse needed a sort order before it could skip anything; DuckDB's extra partitioning helped only one view at the largest scale. For the whole year, no layout helped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are broad historical questions repeated?&lt;/strong&gt; Then stop recomputing them. Rollups made the yearly view 4 to 24 times faster on every engine and raised concurrent capacity by a similar factor, and did little for a single day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which metrics need composable state?&lt;/strong&gt; Sums and counts compose; averages and percentiles don't. A percentile in a summary needs a mergeable representation, which costs storage and returns an approximation whose error depends on bucket size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which dimensions must stay available?&lt;/strong&gt; Every dimension kept in a summary multiplies its size, and a high-cardinality one can make it larger than the data. Decide which questions get the cheap path, and make the system say which path it took.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much resolution can the consumer use?&lt;/strong&gt; Returning more points than pixels cost bytes and time without making the chart more faithful. If short-lived features matter, ask for them explicitly and pay for them in the budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does one answer cost when many people ask at once?&lt;/strong&gt; Under CPU-bound load, capacity was the CPU allowance divided by the work per answer.&lt;/p&gt;

&lt;p&gt;The engines differed in &lt;em&gt;where&lt;/em&gt; they met these limits, and those differences are evidence about this workload on this machine, not general verdicts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; served selective time ranges well with nothing but an index. Its broad scans of 100 million events were I/O-sensitive under the lab's 3 GB container budget and varied widely between repetitions. Rollups still cut the yearly view from 45 s to 10.9 s. Its rollup tables were far larger than the other engines' — 9.8 GB against hundreds of megabytes — for reasons the lab did not isolate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB&lt;/strong&gt; scanned Parquet quickly and spread a single query across all its cores, which made one request fast and left no headroom for a second user in this setup. Its partitioned layout was a net cost at small scale here — consistent with the overhead of many small files on this data, and not evidence about partitioning in general. Under overload, interrupted heavy queries took tens of seconds to wind down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClickHouse&lt;/strong&gt; had the fastest broad scans once its data was sorted, and a single rollup answer left part of its CPU free for other users. Its exact yearly percentile needed about 600 MB of memory; an earlier out-of-memory failure turned out not to be attributable to that query alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Practical recommendations
&lt;/h2&gt;

&lt;p&gt;These follow from the measurements on this workload and this machine. They are starting points, not laws.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reduce data before transport.&lt;/strong&gt; Don't ship event-level rows when the visualization needs aggregates; send the question and the chart's width, return buckets. It was the largest single improvement in the lab, on every engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduce reads through physical access — after checking what you already have.&lt;/strong&gt; Sorting and partitioning by time helped narrow ranges; a plain time index was already most of the win on PostgreSQL, and small partitioned files cost more than they saved on small DuckDB datasets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the state needed to compose the metric, not a final value that cannot be composed.&lt;/strong&gt; Sums and counts instead of averages; for percentiles, a mergeable representation — this lab used a fixed-bin histogram — and label the result as approximate, because its error depends on how many events a bucket holds. Verify the summary against raw data before trusting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop recomputing the widest questions you keep asking.&lt;/strong&gt; Rollups paid off on the year and barely on the day. Choose grains that fit the bucket sizes your charts request: a 12-hour bucket cannot be filled from daily cells.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduce optionality deliberately.&lt;/strong&gt; Keep high-cardinality dimensions out of summaries, give them their own access path, and make the system refuse or route — with a reason — when a summary cannot answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget the representation for its consumer.&lt;/strong&gt; Ask for the screen's resolution, and name the features that must survive it: when short-lived extremes matter, return an envelope per point instead of more points, and count it in the budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduce the work per answer before trying to serve more answers at once.&lt;/strong&gt; Throughput topped out at the CPU allowance divided by the CPU one answer cost; price every addition to an answer in users, not only in milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the benchmark as code under test.&lt;/strong&gt; Several confident numbers here measured a bug in the harness, the builder or the environment. Make every measurement prove which question, engine and dataset it belongs to, and keep discarded runs on record.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where should a chart's numbers come from?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Does the request need individual events (an export, a drill-down to rows)?
├─ yes → serve event-level data, paged or streamed — a different access path from charts
└─ no  → aggregate on the server; at most as many buckets as the chart has pixels
         │
         Must short-lived extremes stay visible?
         ├─ yes → add a min/max envelope from a fine base, counted in the value budget
         │        (it keeps each sub-bucket's peak, not single events or exact times;
         │         to locate a spike, ask for a narrower range)
         └─ no  → the bucket value alone
         │
         Exact percentile required?
         ├─ yes → event-level aggregation on a pruning-friendly layout
         └─ no  → Does a rollup keep every filtered dimension?
                  ├─ no  → event-level aggregation, and say why
                  └─ yes → rollup cells, with event-level data for edges they don't cover

One answer is fast, but many users saturate the engine?
└─ capacity ≈ CPU allowance ÷ CPU per answer: remove work from each answer first,
   and price additions such as the envelope in users. Past that point, more users only add waiting.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The chart at the start of this article is 1,200 pixels wide. Behind it were 100 million events. Everything in between was a series of decisions about where to discard information.&lt;/p&gt;

&lt;p&gt;The naive dashboard discarded nothing until the browser. Server-side aggregation moved the reduction into the engine; a pruning layout let the engine skip data — but only for questions narrower than the data. Rollups stopped recomputing history, and only worked because they kept composable state rather than finished answers. Their size showed that a summary is small only while it gives up enough questions. The screen set a budget for the answer, and the spikes showed that the budget is not just a number of points but a choice of what each point must carry. Under many users, all of it became a single quantity: the work each answer costs.&lt;/p&gt;

&lt;p&gt;One rule ran through every part:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Find the earliest layer where information can be safely discarded.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Earliest&lt;/em&gt;, because every layer before the reduction pays for the full volume. &lt;em&gt;Safely&lt;/em&gt;, because every reduction is also a decision about which questions remain answerable — and correctness, composability and fidelity are what make it safe.&lt;/p&gt;

&lt;p&gt;A data-heavy frontend is not a rendering problem with a database behind it. It is an information-reduction pipeline, and its performance is decided long before the first component renders.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The lab, the full methodology and every raw measurement: &lt;a href="https://github.com/godfreyd/100m-to-1200px" rel="noopener noreferrer"&gt;https://github.com/godfreyd/100m-to-1200px&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>performance</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>You Don't Modularise a Frontend by Moving Files</title>
      <dc:creator>Sergey</dc:creator>
      <pubDate>Sat, 29 Aug 2026 10:23:25 +0000</pubDate>
      <link>https://dev.to/bocharov/you-dont-modularise-a-frontend-by-moving-files-c6o</link>
      <guid>https://dev.to/bocharov/you-dont-modularise-a-frontend-by-moving-files-c6o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F37wvs9za2t8hmmo0h7qb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F37wvs9za2t8hmmo0h7qb.png" alt="You Don't Modularise a Frontend by Moving Files" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Notes from trying to give one React component a place it could actually call its own
&lt;/h3&gt;

&lt;p&gt;We wanted to split a large frontend into packages.&lt;/p&gt;

&lt;p&gt;This sounded, at first, like a file-moving problem.&lt;/p&gt;

&lt;p&gt;We had a component. Let's call it &lt;code&gt;WeatherCard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It showed the current weather, allowed the user to save a location, sent analytics events, behaved slightly differently on the home page and in search results, participated in an experiment, and knew whether the application was running on mobile.&lt;/p&gt;

&lt;p&gt;Nothing particularly exotic.&lt;/p&gt;

&lt;p&gt;It lived here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
  feed/
    cards/
      weather/
        WeatherCard.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We wanted it here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/
  weather-card/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So we moved it.&lt;/p&gt;

&lt;p&gt;That took an afternoon.&lt;/p&gt;

&lt;p&gt;Modularising it took considerably longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The files moved. The application came with them.
&lt;/h2&gt;

&lt;p&gt;The first package looked promising.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/
  weather-card/
    WeatherCard.tsx
    WeatherIcon.tsx
    weather.css
    index.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It had a &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It had tests.&lt;/p&gt;

&lt;p&gt;It even had a pleasantly small public export.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WeatherCard&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./WeatherCard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I looked at the imports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSelector&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-redux&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTranslation&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../i18n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useExperiment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../experiments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useAnalytics&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../analytics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRouter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;selectSavedLocations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package was physically separate and architecturally attached to almost everything around it.&lt;/p&gt;

&lt;p&gt;An import is at least honest about this. Package managers can see it. Build tools can see it. Static analysis can complain about it.&lt;/p&gt;

&lt;p&gt;Other dependencies are more discreet.&lt;/p&gt;

&lt;p&gt;Our component appeared to have a wonderfully small API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt; &lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was not really its API.&lt;/p&gt;

&lt;p&gt;Its inputs looked more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WeatherCard
    │
    ├── item
    ├── Redux store
    ├── router
    ├── translations
    ├── experiments
    ├── analytics
    ├── application type
    └── device configuration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only one happened to be written in the function signature.&lt;/p&gt;

&lt;p&gt;The others arrived through the atmosphere.&lt;/p&gt;

&lt;p&gt;This wasn't an argument against Redux or React Context. Both had been useful. The problem appeared only when we tried to establish a boundary.&lt;/p&gt;

&lt;p&gt;Global context doesn't remove dependencies.&lt;/p&gt;

&lt;p&gt;It makes some of them easier not to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  An adapter helped
&lt;/h2&gt;

&lt;p&gt;We introduced an adapter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WeatherCardAdapter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;rawItem&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;savedLocations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectSavedLocations&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useExperiment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compact-weather-card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;analytics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnalytics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt;
      &lt;span class="na"&gt;rawItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rawItem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;savedLocations&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;savedLocations&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onOpenLocation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WeatherCard&lt;/code&gt; no longer needed to know where these things came from.&lt;/p&gt;

&lt;p&gt;The application-facing dependencies accumulated in the adapter instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
     │
     ▼
WeatherCardAdapter
     │
     ▼
 WeatherCard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was an immediate problem.&lt;/p&gt;

&lt;p&gt;The component interface became ugly.&lt;/p&gt;

&lt;h2&gt;
  
  
  We fixed the ugly interface
&lt;/h2&gt;

&lt;p&gt;The real component looked closer to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt;
  &lt;span class="na"&gt;rawItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rawItem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;savedLocations&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;savedLocations&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;translate&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;compact&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onOpenLocation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onOpenLocation&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onSaveLocation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onSaveLocation&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onEvent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;pageType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pageType&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine props.&lt;/p&gt;

&lt;p&gt;We had taken a component with a neat API and turned it into this.&lt;/p&gt;

&lt;p&gt;So we did what seemed sensible.&lt;/p&gt;

&lt;p&gt;We introduced &lt;code&gt;WeatherCardContext&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WeatherCardContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;savedLocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SavedLocation&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Translate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;openLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;saveLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sendEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WeatherEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pageType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PageType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component became tidy again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt;
  &lt;span class="na"&gt;rawItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rawItem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;weatherCardContext&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Much better.&lt;/p&gt;

&lt;p&gt;For perhaps a week, I thought we had found the right abstraction.&lt;/p&gt;

&lt;p&gt;Then another card needed the same translation function.&lt;/p&gt;

&lt;p&gt;And the same navigation behaviour.&lt;/p&gt;

&lt;p&gt;But not the same analytics.&lt;/p&gt;

&lt;p&gt;A third card needed analytics and device information, but had a different interpretation of &lt;code&gt;pageType&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We started constructing context objects.&lt;/p&gt;

&lt;p&gt;Then helpers for constructing context objects.&lt;/p&gt;

&lt;p&gt;Then a common context from which card-specific contexts could be constructed.&lt;/p&gt;

&lt;p&gt;The dependency graph we had been trying to remove was slowly reappearing inside one object.&lt;/p&gt;

&lt;p&gt;Worse, code review had become harder.&lt;/p&gt;

&lt;p&gt;This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;said almost nothing about what changing &lt;code&gt;WeatherCard&lt;/code&gt; might affect.&lt;/p&gt;

&lt;p&gt;We had successfully shortened the interface.&lt;/p&gt;

&lt;p&gt;We had also hidden it again.&lt;/p&gt;

&lt;p&gt;So we deleted &lt;code&gt;WeatherCardContext&lt;/code&gt; and put the ugly props back.&lt;/p&gt;

&lt;p&gt;That felt like going backwards.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The long interface was information.&lt;/p&gt;

&lt;p&gt;Some props belonged to the weather card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rawItem
savedLocations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some were capabilities supplied by the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;onOpenLocation
onSaveLocation
onEvent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some described presentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compact
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And some looked suspicious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;isMobile
pageType
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why did a weather card need to know that it was on mobile?&lt;/p&gt;

&lt;p&gt;The answer was in the rendering code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CompactTemperature&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;temperature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TemperatureWithDetails&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;temperature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It didn't care about mobile.&lt;/p&gt;

&lt;p&gt;It cared about presentation.&lt;/p&gt;

&lt;p&gt;So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;displayMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tiny change, but a useful one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isMobile&lt;/code&gt; described the world outside the card.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;displayMode&lt;/code&gt; described the card.&lt;/p&gt;

&lt;p&gt;The ugly interface had shown us where to look.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sometimes an ugly interface is an X-ray.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Then we found the state
&lt;/h2&gt;

&lt;p&gt;Saving a location changed global Redux state.&lt;/p&gt;

&lt;p&gt;That seemed reasonable. The application needed to know which locations were saved.&lt;/p&gt;

&lt;p&gt;The card also stored whether its save animation was running in Redux.&lt;/p&gt;

&lt;p&gt;That seemed less reasonable.&lt;/p&gt;

&lt;p&gt;Then we found whether the tooltip had been dismissed.&lt;/p&gt;

&lt;p&gt;Also Redux.&lt;/p&gt;

&lt;p&gt;Whether the expanded forecast was open.&lt;/p&gt;

&lt;p&gt;Redux.&lt;/p&gt;

&lt;p&gt;Whether the user was currently hovering over the card.&lt;/p&gt;

&lt;p&gt;Thankfully, not Redux.&lt;/p&gt;

&lt;p&gt;We had apparently drawn a boundary somewhere. We just couldn't explain why it was there.&lt;/p&gt;

&lt;p&gt;Asking whether something belonged in Redux wasn't getting us very far, so we changed the question.&lt;/p&gt;

&lt;p&gt;Who owns this state?&lt;/p&gt;

&lt;p&gt;The expanded forecast existed only while the card existed.&lt;/p&gt;

&lt;p&gt;The save animation existed because the card was displaying it.&lt;/p&gt;

&lt;p&gt;The tooltip belonged to that interaction.&lt;/p&gt;

&lt;p&gt;They moved inside.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useWeatherCard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setExpanded&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;saving&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSaving&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tooltipVisible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTooltipVisible&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Saved locations were different.&lt;/p&gt;

&lt;p&gt;Another card could save the same location. Navigation could show the number of saved locations. The information survived the lifetime of any individual &lt;code&gt;WeatherCard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That stayed outside.&lt;/p&gt;

&lt;p&gt;This gave us a rule we could actually use:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If state needs to be observed outside a boundary, either the observer belongs inside the boundary or the state doesn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was a more useful question than choosing between Redux and &lt;code&gt;useState&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The application knew what weather looked like
&lt;/h2&gt;

&lt;p&gt;Data was next.&lt;/p&gt;

&lt;p&gt;The API returned something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"london"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"units"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"celsius"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forecast"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alerts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application parsed this response.&lt;/p&gt;

&lt;p&gt;The feed extracted fields.&lt;/p&gt;

&lt;p&gt;The card received the resulting object.&lt;/p&gt;

&lt;p&gt;This had always worked.&lt;/p&gt;

&lt;p&gt;It also meant that changing the server representation of a weather card could require changes in code that otherwise had nothing to do with weather.&lt;/p&gt;

&lt;p&gt;We tried making the data opaque to the application.&lt;/p&gt;

&lt;p&gt;The feed needed surprisingly little:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FeedItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rawItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest could cross the boundary untouched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server
   │
   ▼
Feed
   │
   │ rawItem
   ▼
WeatherCard
   │
   ▼
Weather parser
   │
   ▼
Weather model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser moved into the package.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WeatherItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;units&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oneOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;celsius&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fahrenheit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;forecast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Forecast&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was one of the quieter changes in the migration.&lt;/p&gt;

&lt;p&gt;It removed no visible feature. It probably made no benchmark move.&lt;/p&gt;

&lt;p&gt;What changed was who had to understand the data.&lt;/p&gt;

&lt;p&gt;The feed stopped understanding weather.&lt;/p&gt;

&lt;p&gt;That was useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  A string that wasn't a string
&lt;/h2&gt;

&lt;p&gt;Packages gave us another tool: compilation boundaries.&lt;/p&gt;

&lt;p&gt;Suppose some application code accepted a location identifier.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LocationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openWeather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LocationId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later the contract changed. &lt;code&gt;openWeather&lt;/code&gt; could no longer accept any location identifier. It required the canonical location selected after resolving aliases.&lt;/p&gt;

&lt;p&gt;Unfortunately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LocationId = string
CanonicalLocationId = string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From TypeScript's point of view, nothing had happened.&lt;/p&gt;

&lt;p&gt;The compiler was correct.&lt;/p&gt;

&lt;p&gt;It was proving the wrong thing.&lt;/p&gt;

&lt;p&gt;We wanted consumers to acknowledge the semantic change, so we gave the new identifier a distinct type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CanonicalLocationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;__brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CanonicalLocationId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suddenly the build failed in several places.&lt;/p&gt;

&lt;p&gt;Good.&lt;/p&gt;

&lt;p&gt;Each error represented a consumer whose assumption needed to be reconsidered.&lt;/p&gt;

&lt;p&gt;The branded type wasn't particularly sophisticated. What interested me was what the failure meant.&lt;/p&gt;

&lt;p&gt;We had turned part of an architectural migration into something the compiler could inspect.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A boundary described in documentation is an intention. A boundary that can fail the build has teeth.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  One experiment, three teams
&lt;/h2&gt;

&lt;p&gt;The compact layout had started as a single experiment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compact-feed-cards
      │
      ├── WeatherCard
      ├── NewsCard
      └── SportsCard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This had seemed like sensible reuse.&lt;/p&gt;

&lt;p&gt;The three cards were testing roughly the same visual idea, so one flag was simpler than three.&lt;/p&gt;

&lt;p&gt;Then the weather implementation was ready.&lt;/p&gt;

&lt;p&gt;The news implementation was nearly ready.&lt;/p&gt;

&lt;p&gt;Sports had discovered an interaction problem and needed another week.&lt;/p&gt;

&lt;p&gt;Nothing was broken. There was no technical incident. We simply couldn't finish the weather experiment without deciding what to do about sports.&lt;/p&gt;

&lt;p&gt;The options were not attractive.&lt;/p&gt;

&lt;p&gt;We could wait.&lt;/p&gt;

&lt;p&gt;We could add special-case logic around the shared experiment.&lt;/p&gt;

&lt;p&gt;We could coordinate a partial rollout between teams.&lt;/p&gt;

&lt;p&gt;Or we could admit that the experiment wasn't actually one thing.&lt;/p&gt;

&lt;p&gt;We split it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compact-weather-card
compact-news-card
compact-sports-card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three flags where there had been one.&lt;/p&gt;

&lt;p&gt;Less DRY.&lt;/p&gt;

&lt;p&gt;The weather team shipped.&lt;/p&gt;

&lt;p&gt;A few days later news shipped.&lt;/p&gt;

&lt;p&gt;Sports changed its implementation and shipped after that.&lt;/p&gt;

&lt;p&gt;The interesting dependency had never appeared in the module graph. There was no import from weather to sports. There was no shared component.&lt;/p&gt;

&lt;p&gt;They shared a release decision.&lt;/p&gt;

&lt;p&gt;That was enough to couple them.&lt;/p&gt;

&lt;p&gt;After that I became less interested in counting dependencies and more interested in looking for things that had to change together.&lt;/p&gt;

&lt;p&gt;Sometimes that was an import.&lt;/p&gt;

&lt;p&gt;Sometimes state.&lt;/p&gt;

&lt;p&gt;Sometimes a server format.&lt;/p&gt;

&lt;p&gt;Sometimes an experiment.&lt;/p&gt;

&lt;p&gt;Sometimes a release process.&lt;/p&gt;

&lt;p&gt;They looked different in code.&lt;/p&gt;

&lt;p&gt;Operationally, they behaved rather similarly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture got worse
&lt;/h2&gt;

&lt;p&gt;After the first few migrations, our architecture diagram looked worse than when we started.&lt;/p&gt;

&lt;p&gt;Imagine a feed with sixty card types.&lt;/p&gt;

&lt;p&gt;We had migrated four.&lt;/p&gt;

&lt;p&gt;It now looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Feed
 ├── LegacyNewsCard ───── Redux ── Context ── ...
 ├── LegacySportsCard ─── Redux ── Context ── ...
 ├── LegacyFinanceCard ── Redux ── Context ── ...
 │
 ├── WeatherCardAdapter
 │       │
 │       ▼
 │   WeatherCard
 │
 ├── TravelCardAdapter
 │       │
 │       ▼
 │   TravelCard
 │
 └── ...54 more
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the migration there had been one architecture.&lt;/p&gt;

&lt;p&gt;Now there were two.&lt;/p&gt;

&lt;p&gt;We had more adapters, more package boundaries and more concepts to explain to somebody joining the project.&lt;/p&gt;

&lt;p&gt;Meanwhile product development continued.&lt;/p&gt;

&lt;p&gt;A legacy card would gain a feature while we were extracting another card. Occasionally somebody would add a new global dependency to a component we intended to migrate next.&lt;/p&gt;

&lt;p&gt;For a while this felt like evidence that the migration was too slow.&lt;/p&gt;

&lt;p&gt;The tempting response was to accelerate it.&lt;/p&gt;

&lt;p&gt;Take ten cards.&lt;/p&gt;

&lt;p&gt;Stop touching them for product work.&lt;/p&gt;

&lt;p&gt;Move them together.&lt;/p&gt;

&lt;p&gt;Clean up the intermediate abstractions afterwards.&lt;/p&gt;

&lt;p&gt;On paper this was much more efficient.&lt;/p&gt;

&lt;p&gt;It also meant that instead of learning whether the boundary worked from one card, we would learn after ten.&lt;/p&gt;

&lt;p&gt;So we kept the migration deliberately uneven.&lt;/p&gt;

&lt;p&gt;Four cards became six.&lt;/p&gt;

&lt;p&gt;Six became nine.&lt;/p&gt;

&lt;p&gt;The remaining cards stayed exactly as awkward as they had been before.&lt;/p&gt;

&lt;p&gt;This had an unexpected benefit.&lt;/p&gt;

&lt;p&gt;The new architecture had to earn its keep while surrounded by the old one.&lt;/p&gt;

&lt;p&gt;If extracting &lt;code&gt;WeatherCard&lt;/code&gt; required changing all sixty cards, the design was not useful yet.&lt;/p&gt;

&lt;p&gt;If the new package required every feed to adopt a new state model simultaneously, the design was not useful yet.&lt;/p&gt;

&lt;p&gt;If a parser could move only after the entire API response was redesigned, it was not useful yet.&lt;/p&gt;

&lt;p&gt;The constraints of incremental migration rejected quite a few elegant ideas for us.&lt;/p&gt;

&lt;p&gt;I am grateful for that now.&lt;/p&gt;

&lt;p&gt;An architecture that works only after the migration is finished has a bootstrapping problem.&lt;/p&gt;

&lt;p&gt;For a large, continuously changing system, the intermediate states are not a brief inconvenience between two architectures.&lt;/p&gt;

&lt;p&gt;They are where you live.&lt;/p&gt;

&lt;p&gt;Our migration would take months.&lt;/p&gt;

&lt;p&gt;For most of that time, the transitional architecture &lt;em&gt;was&lt;/em&gt; the architecture.&lt;/p&gt;

&lt;p&gt;That changed the question we asked about new abstractions.&lt;/p&gt;

&lt;p&gt;Not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this get us to the cleanest final design?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;More often:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this make the next card cheaper to change without making the remaining fifty-one harder to live with?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That produced smaller steps.&lt;/p&gt;

&lt;p&gt;It also meant we could stop.&lt;/p&gt;

&lt;p&gt;Had priorities changed after twelve cards, we would still have had twelve cards with clearer ownership and forty-eight functioning legacy cards.&lt;/p&gt;

&lt;p&gt;There would have been no half-built platform waiting for the rest of the application to catch up.&lt;/p&gt;

&lt;p&gt;The destination still mattered.&lt;/p&gt;

&lt;p&gt;The ability to stop on the way mattered more than I had expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The card became boring
&lt;/h2&gt;

&lt;p&gt;Eventually &lt;code&gt;WeatherCard&lt;/code&gt; became surprisingly uninteresting.&lt;/p&gt;

&lt;p&gt;It received data.&lt;/p&gt;

&lt;p&gt;It parsed the data it owned.&lt;/p&gt;

&lt;p&gt;It managed its local interactions.&lt;/p&gt;

&lt;p&gt;It asked the application to perform things outside its boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherCard&lt;/span&gt;
  &lt;span class="na"&gt;rawItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rawItem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;displayMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"compact"&lt;/span&gt;
  &lt;span class="na"&gt;savedLocations&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;savedLocations&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onSaveLocation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;saveLocation&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onOpenLocation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openLocation&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sendWeatherEvent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It didn't know which state library the application used.&lt;/p&gt;

&lt;p&gt;It didn't know which router it used.&lt;/p&gt;

&lt;p&gt;It didn't know where experiments came from.&lt;/p&gt;

&lt;p&gt;It didn't know whether it was rendered in the main application, Storybook or a test harness.&lt;/p&gt;

&lt;p&gt;The adapter knew considerably more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                   Application
                        │
       ┌────────────────┼────────────────┐
       │                │                │
     Router          State          Experiments
       │                │                │
       └────────────────┼────────────────┘
                        ▼
                WeatherCardAdapter
                        │
                        ▼
                   WeatherCard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had not eliminated the complexity.&lt;/p&gt;

&lt;p&gt;We had changed where knowledge was allowed to accumulate.&lt;/p&gt;

&lt;p&gt;I think that distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The files were the easy part
&lt;/h2&gt;

&lt;p&gt;Looking back, moving &lt;code&gt;WeatherCard&lt;/code&gt; into &lt;code&gt;packages/weather-card&lt;/code&gt; was one of the least interesting things we did.&lt;/p&gt;

&lt;p&gt;The package became useful only as other changes accumulated around it.&lt;/p&gt;

&lt;p&gt;Dependencies that had been ambient became explicit.&lt;/p&gt;

&lt;p&gt;State acquired owners.&lt;/p&gt;

&lt;p&gt;Data was interpreted closer to the code that understood it.&lt;/p&gt;

&lt;p&gt;Some semantic contracts became compilation failures.&lt;/p&gt;

&lt;p&gt;Release decisions that looked shared turned out not to be.&lt;/p&gt;

&lt;p&gt;And the old application remained operational while all of this happened.&lt;/p&gt;

&lt;p&gt;I started with a fairly mechanical picture of modularity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one large thing
      ↓
several smaller things
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That picture isn't wrong.&lt;/p&gt;

&lt;p&gt;It just doesn't say very much.&lt;/p&gt;

&lt;p&gt;The question I find more useful now is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If this part of the system changes, how much of the rest of the system needs to know?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For &lt;code&gt;WeatherCard&lt;/code&gt;, the answer gradually became: less.&lt;/p&gt;

&lt;p&gt;Not nothing.&lt;/p&gt;

&lt;p&gt;Just less.&lt;/p&gt;

&lt;p&gt;That turned out to be enough.&lt;/p&gt;

</description>
      <category>react</category>
      <category>modularise</category>
      <category>redux</category>
      <category>legacy</category>
    </item>
    <item>
      <title>Teaching an AI to Review Frontend Code</title>
      <dc:creator>Sergey</dc:creator>
      <pubDate>Sat, 29 Aug 2026 09:13:56 +0000</pubDate>
      <link>https://dev.to/bocharov/teaching-an-ai-to-review-frontend-code-1cc8</link>
      <guid>https://dev.to/bocharov/teaching-an-ai-to-review-frontend-code-1cc8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F812iz44dwn3wx5bmaodp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F812iz44dwn3wx5bmaodp.png" alt="Teaching an AI to Review Frontend Code" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have been experimenting with AI-assisted code review for a while.&lt;/p&gt;

&lt;p&gt;The first attempts were not particularly useful.&lt;/p&gt;

&lt;p&gt;They were not &lt;em&gt;wrong&lt;/em&gt;, exactly. The reviews found things: a questionable name here, a function that might be extracted there, an opportunity for &lt;code&gt;useMemo&lt;/code&gt;, perhaps an &lt;code&gt;any&lt;/code&gt; that could be made more precise.&lt;/p&gt;

&lt;p&gt;There was plenty of activity.&lt;/p&gt;

&lt;p&gt;There was rather less reviewing.&lt;/p&gt;

&lt;p&gt;What bothered me was the lack of proportion. A naming suggestion and a possible race condition could appear next to each other as though they deserved roughly the same amount of attention.&lt;/p&gt;

&lt;p&gt;They do not.&lt;/p&gt;

&lt;p&gt;That observation led to a small experiment.&lt;/p&gt;

&lt;p&gt;Instead of asking an AI to &lt;em&gt;find problems in code&lt;/em&gt;, I tried to describe how I wanted a review to proceed.&lt;/p&gt;

&lt;p&gt;I ended up writing a code-review skill.&lt;/p&gt;

&lt;p&gt;This is a story about what went into it, what changed along the way, and what the exercise taught me about code review itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version was a checklist
&lt;/h2&gt;

&lt;p&gt;This seemed sensible.&lt;/p&gt;

&lt;p&gt;Frontend code has plenty of things worth checking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TypeScript types&lt;/li&gt;
&lt;li&gt;React effects&lt;/li&gt;
&lt;li&gt;accessibility&lt;/li&gt;
&lt;li&gt;error handling&lt;/li&gt;
&lt;li&gt;performance&lt;/li&gt;
&lt;li&gt;tests&lt;/li&gt;
&lt;li&gt;state management&lt;/li&gt;
&lt;li&gt;API contracts&lt;/li&gt;
&lt;li&gt;naming&lt;/li&gt;
&lt;li&gt;duplication&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I wrote them down.&lt;/p&gt;

&lt;p&gt;The result was comprehensive.&lt;/p&gt;

&lt;p&gt;It was also not very good.&lt;/p&gt;

&lt;p&gt;Given enough checklist items, an AI can nearly always find something to say. The difficulty is that software engineering is rarely about finding the largest number of technically defensible observations.&lt;/p&gt;

&lt;p&gt;It is about deciding what matters.&lt;/p&gt;

&lt;p&gt;Consider a small change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are several things one might say about these four lines.&lt;/p&gt;

&lt;p&gt;But the interesting question, for me, is what happens when &lt;code&gt;userId&lt;/code&gt; changes before the first request completes.&lt;/p&gt;

&lt;p&gt;Request A starts for user 10.&lt;/p&gt;

&lt;p&gt;The component changes.&lt;/p&gt;

&lt;p&gt;Request B starts for user 11.&lt;/p&gt;

&lt;p&gt;B completes first.&lt;/p&gt;

&lt;p&gt;Then A completes.&lt;/p&gt;

&lt;p&gt;The code is still short. The types may still be correct. The linter may be entirely content.&lt;/p&gt;

&lt;p&gt;The UI may now be showing the wrong user.&lt;/p&gt;

&lt;p&gt;That was the first useful shift in the skill:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Review the behaviour around the code, not merely the code in the diff.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A review needs an idea of what matters
&lt;/h2&gt;

&lt;p&gt;Once I had noticed this, the checklist began to look like the wrong abstraction.&lt;/p&gt;

&lt;p&gt;The skill needed priorities.&lt;/p&gt;

&lt;p&gt;I eventually wrote this near the beginning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Review code as a production risk audit, not a style pass.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence became surprisingly useful.&lt;/p&gt;

&lt;p&gt;It gives the review a direction.&lt;/p&gt;

&lt;p&gt;A resource leak matters before a naming preference.&lt;/p&gt;

&lt;p&gt;A broken permission boundary matters before duplicated code.&lt;/p&gt;

&lt;p&gt;Losing a user's changes matters before whether a helper should be extracted.&lt;/p&gt;

&lt;p&gt;An out-of-order response matters before an unnecessary allocation.&lt;/p&gt;

&lt;p&gt;This does not make maintainability, performance or style irrelevant.&lt;/p&gt;

&lt;p&gt;It simply gives them somewhere to stand.&lt;/p&gt;

&lt;p&gt;The review starts with things that can break user trust or cause the running system to diverge from its intended behaviour: lifecycle leaks, asynchronous races, state integrity, permissions, persistence and other high-impact paths.&lt;/p&gt;

&lt;p&gt;That ordering made the reviews noticeably quieter.&lt;/p&gt;

&lt;p&gt;I considered that progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract before implementation
&lt;/h2&gt;

&lt;p&gt;The next problem was more subtle.&lt;/p&gt;

&lt;p&gt;How can a reviewer decide whether code is correct without first deciding what the code is supposed to do?&lt;/p&gt;

&lt;p&gt;This sounds obvious when written down.&lt;/p&gt;

&lt;p&gt;In practice, I had been skipping it.&lt;/p&gt;

&lt;p&gt;So the first review gate became &lt;strong&gt;Contract&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The contract is not necessarily written in one place. It may be distributed across the pull-request description, types, tests, public exports, documentation, neighbouring code and the vocabulary of the project.&lt;/p&gt;

&lt;p&gt;Even the word &lt;em&gt;refactor&lt;/em&gt; contains a contract.&lt;/p&gt;

&lt;p&gt;If I say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is only a refactor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I am making a fairly strong claim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Observable behaviour has not changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Similarly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This improves performance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;contains another claim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There is a measurable improvement, and the relevant behaviour has been preserved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once I started treating those statements as claims rather than descriptions, the review changed.&lt;/p&gt;

&lt;p&gt;Instead of asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this implementation good?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;the more useful question became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What has this change promised, and what would convince me that the promise holds?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I like this framing because it removes quite a lot of personal taste from the conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff is smaller than the change
&lt;/h2&gt;

&lt;p&gt;Another thing I kept running into was the boundary of the pull request.&lt;/p&gt;

&lt;p&gt;A prop changes in one component, but the behavioural change belongs to every consumer.&lt;/p&gt;

&lt;p&gt;A query key changes in one hook, but the consequence belongs to the cache.&lt;/p&gt;

&lt;p&gt;An API field changes in one type, but the actual surface may run through validation, mapping, persistence and several pieces of UI.&lt;/p&gt;

&lt;p&gt;A new option is particularly interesting.&lt;/p&gt;

&lt;p&gt;Adding it is easy.&lt;/p&gt;

&lt;p&gt;Working out what every existing consumer does when it encounters that option is often where the real review begins.&lt;/p&gt;

&lt;p&gt;So I added another gate: &lt;strong&gt;Impacted surface&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The instruction is essentially to follow changed values and behaviour through the system: props, state, API payloads, events, permissions, loading states, errors, cache keys, exports and side effects.&lt;/p&gt;

&lt;p&gt;This is expensive compared with commenting on the changed lines.&lt;/p&gt;

&lt;p&gt;It is also much closer to what I find myself doing when reviewing an important change manually.&lt;/p&gt;

&lt;p&gt;The diff tells me where the edit happened.&lt;/p&gt;

&lt;p&gt;It does not necessarily tell me where the change happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time deserves its own review
&lt;/h2&gt;

&lt;p&gt;Frontend software has another awkward property: much of its behaviour exists across time.&lt;/p&gt;

&lt;p&gt;A component renders, mounts, updates and unmounts.&lt;/p&gt;

&lt;p&gt;A request starts, gets cancelled, retries, resolves or becomes stale.&lt;/p&gt;

&lt;p&gt;A form is initialised, edited, validated, submitted, rejected, reset and perhaps abandoned halfway through.&lt;/p&gt;

&lt;p&gt;Looking at one moment in these processes hides a surprising number of bugs.&lt;/p&gt;

&lt;p&gt;So the skill began to describe lifecycles explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Component
render → hydrate → mount → update → suspend → unmount

Async work
start → cancel → retry → resolve/reject → stale result → cleanup

Form
initialise → edit → dirty → validate → submit → error/success → reset

Data
fetch → normalise → validate → cache → derive → render → mutate → invalidate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are not meant as universal state machines.&lt;/p&gt;

&lt;p&gt;They are prompts for attention.&lt;/p&gt;

&lt;p&gt;When an effect changes, I want the review to follow it through unmount.&lt;/p&gt;

&lt;p&gt;When a form changes, I want to know what happens after the server rejects the submission.&lt;/p&gt;

&lt;p&gt;When caching changes, I want to know how stale data eventually becomes fresh again.&lt;/p&gt;

&lt;p&gt;This turned out to be one of the more useful additions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence changed the tone of the review
&lt;/h2&gt;

&lt;p&gt;There was still a problem.&lt;/p&gt;

&lt;p&gt;AI is rather good at producing plausible concerns.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This could cause unnecessary renders.&lt;/p&gt;

&lt;p&gt;There may be a race condition here.&lt;/p&gt;

&lt;p&gt;Consider memoising this value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each sentence sounds reasonable.&lt;/p&gt;

&lt;p&gt;None of them is necessarily true.&lt;/p&gt;

&lt;p&gt;I did not want to solve this by making the model more confident.&lt;/p&gt;

&lt;p&gt;Quite the opposite.&lt;/p&gt;

&lt;p&gt;I wanted uncertainty to become part of the output.&lt;/p&gt;

&lt;p&gt;That led to the &lt;strong&gt;Evidence&lt;/strong&gt; gate.&lt;/p&gt;

&lt;p&gt;A correctness change should have a regression test, or a reason why one is impractical.&lt;/p&gt;

&lt;p&gt;A behaviour-preserving refactor should have evidence that the relevant old behaviour still exists.&lt;/p&gt;

&lt;p&gt;A performance improvement should have a measurement.&lt;/p&gt;

&lt;p&gt;And when the available context cannot establish something, the review should say so.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I cannot establish whether this request is cancelled when the component unmounts. The caller lifecycle or a focused cancellation test would close that gap.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I find that much more useful than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Potential memory leak.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The former tells me what is known, what is not known and how to reduce the uncertainty.&lt;/p&gt;

&lt;p&gt;The latter mostly tells me that a memory leak is imaginable.&lt;/p&gt;

&lt;p&gt;Perhaps unsurprisingly, this also made the reviews less argumentative.&lt;/p&gt;

&lt;p&gt;Evidence is easier to discuss than confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refactoring needed a different rule
&lt;/h2&gt;

&lt;p&gt;At some point I tried using the same skill for refactoring.&lt;/p&gt;

&lt;p&gt;That exposed another distinction.&lt;/p&gt;

&lt;p&gt;When I ask for a review, I want the system to challenge behaviour.&lt;/p&gt;

&lt;p&gt;When I ask for a refactor, I usually want the opposite: improve the implementation while being extremely conservative about behaviour.&lt;/p&gt;

&lt;p&gt;So refactoring acquired a rather strict constraint:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Zero behaviour change unless explicitly approved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Public APIs, execution order, side effects, error semantics and observable identity all matter here.&lt;/p&gt;

&lt;p&gt;Even a cleaner implementation is not an improvement if it quietly changes one of them.&lt;/p&gt;

&lt;p&gt;This also changed how I think about abstraction.&lt;/p&gt;

&lt;p&gt;Two pieces of code looking similar is not sufficient reason to combine them.&lt;/p&gt;

&lt;p&gt;I now prefer to ask whether they share a contract and the same edge cases.&lt;/p&gt;

&lt;p&gt;If they do, perhaps there is an abstraction.&lt;/p&gt;

&lt;p&gt;If they merely happen to contain similar lines today, duplication may be cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the skill looks like now
&lt;/h2&gt;

&lt;p&gt;After several iterations, the structure became smaller than the original checklist.&lt;/p&gt;

&lt;p&gt;Conceptually, it is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Contract
   ↓
Impacted surface
   ↓
Failure &amp;amp; divergence
   ↓
Evidence
   ↓
Lower-priority quality
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are specialised expansions underneath it for effects, forms, caches, permissions, dates, APIs, generated files, dependencies and other areas.&lt;/p&gt;

&lt;p&gt;But those are secondary.&lt;/p&gt;

&lt;p&gt;The sequence is the important part.&lt;/p&gt;

&lt;p&gt;It tells the reviewer where to spend attention before telling it what details it might notice.&lt;/p&gt;

&lt;p&gt;That distinction took me longer to see than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  I thought I was writing instructions for an AI
&lt;/h2&gt;

&lt;p&gt;The slightly unexpected part of this experiment is that I am no longer sure the skill is primarily about AI.&lt;/p&gt;

&lt;p&gt;Writing it forced me to make some of my own review habits explicit.&lt;/p&gt;

&lt;p&gt;Why do I sometimes open five files before commenting on one changed line?&lt;/p&gt;

&lt;p&gt;Because I am tracing the impacted surface.&lt;/p&gt;

&lt;p&gt;Why does an innocent-looking &lt;code&gt;useEffect&lt;/code&gt; make me uncomfortable?&lt;/p&gt;

&lt;p&gt;Usually because I have not yet accounted for its lifecycle.&lt;/p&gt;

&lt;p&gt;Why am I reluctant to approve a refactor even though the new code is obviously cleaner?&lt;/p&gt;

&lt;p&gt;Because &lt;em&gt;cleaner&lt;/em&gt; is visible in the diff.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Behaviour preserving&lt;/em&gt; needs evidence.&lt;/p&gt;

&lt;p&gt;Why do some reviews with fifteen comments feel less useful than reviews with one?&lt;/p&gt;

&lt;p&gt;Because the number of observations is not the same thing as the amount of uncertainty removed.&lt;/p&gt;

&lt;p&gt;I started with a fairly mechanical question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How should an AI review frontend code?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ended up with a different one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What do I actually do when I believe I am reviewing code well?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The skill is my current answer.&lt;/p&gt;

&lt;p&gt;I expect it to change.&lt;/p&gt;

&lt;p&gt;That may be the most useful property it has.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>frontend</category>
      <category>codereview</category>
    </item>
  </channel>
</rss>
