<?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: J Lopes</title>
    <description>The latest articles on DEV Community by J Lopes (@offeringofpie).</description>
    <link>https://dev.to/offeringofpie</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%2F2599165%2F272578aa-1f03-4256-a581-f9686bd82510.png</url>
      <title>DEV Community: J Lopes</title>
      <link>https://dev.to/offeringofpie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/offeringofpie"/>
    <language>en</language>
    <item>
      <title>Build Your Own Browser Tools Without a Backend</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Tue, 04 Aug 2026 06:14:29 +0000</pubDate>
      <link>https://dev.to/offeringofpie/build-your-own-browser-tools-without-a-backend-5628</link>
      <guid>https://dev.to/offeringofpie/build-your-own-browser-tools-without-a-backend-5628</guid>
      <description>&lt;h2&gt;
  
  
  Carrot, meet stick!
&lt;/h2&gt;

&lt;p&gt;Once upon a time, I wanted to resize about forty screenshots. The first few search results either wanted an account before they would touch a file, or did three of them and then asked me to register for the rest.&lt;/p&gt;

&lt;p&gt;So... What's stopping them from just doing the thing?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you are not paying for it, you're not the customer; you're the product being sold.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://www.metafilter.com/95152/Userdriven-discontent#3256046" rel="noopener noreferrer"&gt;blue_beetle&lt;/a&gt;, 2010&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Resizing or converting an image can be accomplished by using browser APIs and &lt;strong&gt;a few bits of JavaScript&lt;/strong&gt;, like &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; and &lt;code&gt;toBlob&lt;/code&gt;. Why would the user need yet another login and be tracked for this?&lt;/p&gt;

&lt;p&gt;I don't know... I guess I've gotten jaded about being treated as the product, when I showed up expecting to be a customer.&lt;/p&gt;

&lt;p&gt;For a few years now, instead of relying on black-boxed services for the small things I needed in my daily routine, I ended up building a few myself. Maybe out of revolt, or just a will to understand how the gears turn to make a machine work.&lt;/p&gt;




&lt;h2&gt;
  
  
  You too can make tools, and I promise you'll love it
&lt;/h2&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%2Fuaxc52ec3kjui74jm41i.webp" 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%2Fuaxc52ec3kjui74jm41i.webp" alt="An illustration of a swiss army knife with several different design tools." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've been building tools for myself, for years. For specific things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/colour-palette-generator/" rel="noopener noreferrer"&gt;Colour palette generator&lt;/a&gt;, because I wanted to know how you get a set of colours, and how to convert them into other colour formats.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/word-cloud-generator/" rel="noopener noreferrer"&gt;Word cloud generator&lt;/a&gt;, which turned out to be a collision-detection problem rather than a typography one.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/markdownwysiwyg-converter/" rel="noopener noreferrer"&gt;Markdown and WYSIWYG converter&lt;/a&gt;, to find out what happens when two different editors both think they're in charge of the same document.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/text-fragment-url-generator/" rel="noopener noreferrer"&gt;Text fragment URL generator&lt;/a&gt;, because the &lt;code&gt;#:~:text=&lt;/code&gt; syntax that links straight to a phrase on a page is fiddly enough that I'd rather generate it than type it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  My stack recommendations
&lt;/h3&gt;

&lt;p&gt;I tend to rely on frameworks like React, Vue, Svelte and Astro, as these arrive with routing, build steps and dev server already wired up.&lt;/p&gt;

&lt;p&gt;Frontend stacks might seem intimidating to get into from the beginning, but the learning curve isn't as steep as you might expect, and the learnings from one apply to the rest, in one form or another. The important part is that the stack can be interchangeable after a while.&lt;/p&gt;

&lt;p&gt;I'd avoid relying on AI to help though, as it defeats the purpose of making tools for learning. Otherwise you might as well use anyone else's tool site...&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;DISCLAIMER:&lt;/strong&gt; This is not an article about me tooting my own horn. Please take it as a short manifesto.&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%2Fjlopes.eu%2Fuploads%2Fdrdoom_toot.avif" 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%2Fjlopes.eu%2Fuploads%2Fdrdoom_toot.avif" alt="A comic panel. Namor shouts 'Do not toot it!' as Doctor Doom answers 'Fool! Doctor Doom toots as he pleases!'" width="438" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Spidey Super Stories #53&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  But first, my tools!
&lt;/h2&gt;

&lt;p&gt;This year I decided to consolidate them in &lt;a href="https://jlopes.eu/tools/" rel="noopener noreferrer"&gt;/tools&lt;/a&gt;. Every tool page links to its own file on &lt;a href="https://github.com/offeringofpie/tools" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, so users can see how the gears work for themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  For code developers and enthusiasts
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/minify-beautify" rel="noopener noreferrer"&gt;Minify &amp;amp; Beautify&lt;/a&gt; shrinks huge swathes of code down to one line to save space, then spreads it back out to be readable. The &lt;code&gt;detectCode&lt;/code&gt; function works out whether you're pasting HTML, CSS, JavaScript or SVG as you type, which is a &lt;strong&gt;tokeniser in disguise&lt;/strong&gt;: it has to break the code into meaningful pieces before it can tell you what it's looking at.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/regex-helper" rel="noopener noreferrer"&gt;Regex Tester&lt;/a&gt; is honestly just for me. &lt;a href="https://regex101.com/" rel="noopener noreferrer"&gt;regex101&lt;/a&gt; already does the job better than I ever will, for free.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/nth-child" rel="noopener noreferrer"&gt;:nth-child Helper&lt;/a&gt; lets you click the elements you want and works out the CSS formulas that cover them. Finding the shortest set of formulas meant reaching for the greatest common divisor, which I did not expect from a styling tool.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/flexbox-generator" rel="noopener noreferrer"&gt;Flexbox Generator&lt;/a&gt; is a playground for every &lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Flexbox" rel="noopener noreferrer"&gt;CSS flexbox&lt;/a&gt; property at once, so you can watch what each one does instead of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/cron-helper" rel="noopener noreferrer"&gt;Cron Helper&lt;/a&gt; translates cron values into plain English, with &lt;a href="https://github.com/bradymholt/cRonstrue" rel="noopener noreferrer"&gt;cRonstrue&lt;/a&gt; doing the parsing under the hood.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/icon-search" rel="noopener noreferrer"&gt;Icon Search&lt;/a&gt; covers thousands of icons. Rendering that many at once is what taught me list virtualization: only the icons currently in view exist in the page, and the rest get built as you scroll.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  For designers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/image-resizer" rel="noopener noreferrer"&gt;Image Resizer&lt;/a&gt; does bulk resizing with aspect-ratio presets, an optional crop with pan, and a zip download at the end. However many images you throw at it they go through in one pass, and &lt;strong&gt;the files never leave your computer&lt;/strong&gt;. This is the tool that came out of the complaint at the top of this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/colour-palette" rel="noopener noreferrer"&gt;Colour Palette&lt;/a&gt; generates random palettes for theming, and pulls them out of any image you drop in. It's the one that made me want to consolidate everything into one site, and it's where I learned that the mathematical distance between two RGB colours has nothing to do with how different they look. The pixels get converted into &lt;a href="https://en.wikipedia.org/wiki/CIELAB_color_space" rel="noopener noreferrer"&gt;Lab&lt;/a&gt; first, a colour space built so that equal distances look equally different to the eye.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/svg-optimizer" rel="noopener noreferrer"&gt;SVG Optimizer&lt;/a&gt; strips the unnecessary parts out of exported SVGs, with &lt;a href="https://svgo.dev/" rel="noopener noreferrer"&gt;svgo/browser&lt;/a&gt; under the hood and my preferred compression settings baked in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  For text wranglers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/character-map" rel="noopener noreferrer"&gt;Character Map&lt;/a&gt; needed a huge (&lt;strong&gt;7MB+&lt;/strong&gt;) JSON file of every Unicode code point, and the browser's own IndexedDB to keep it between visits. Keeping it fast is the reason I wrote &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;Understanding List Virtualization&lt;/a&gt;. Did I need to make this? Not really, &lt;a href="https://www.amp-what.com/" rel="noopener noreferrer"&gt;&amp;amp;what;&lt;/a&gt; did it well already, but I wanted to learn &amp;lt;3&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/markdown-editor" rel="noopener noreferrer"&gt;Markdown Editor&lt;/a&gt; is built on &lt;a href="https://tiptap.dev/" rel="noopener noreferrer"&gt;tiptap&lt;/a&gt;, with &lt;code&gt;tiptap-markdown&lt;/code&gt; and &lt;code&gt;marked&lt;/code&gt; handling the markdown side. Loved learning how to detect formatting on paste, and how to export to the clipboard.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/text-transformer" rel="noopener noreferrer"&gt;Text Transformer&lt;/a&gt; was mostly made for fun. Who wouldn't want their own way to change text to z̸̫̖̮͇̫̯͈̥̅͋̂̉̌̉͒a̪̖͈̲͔̪̫̍͗̐̃̊̂̍̌ͅl̯͔̟̠̝͉̳̻̅́̈́̊̿̉̇̌g̖̝͈̪̖͐͌̀̿͐o̼̜̼̯͓̺̤̲͑̅͑͆̿̇̆͌?&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/lorem-ipsum" rel="noopener noreferrer"&gt;Lorem Ipsum&lt;/a&gt; fills a page with placeholder text. It was easier to figure out than I expected, so I kept adding flavours, &lt;strong&gt;including corporate speak&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Web
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/utm-builder" rel="noopener noreferrer"&gt;UTM Builder&lt;/a&gt; assembles marketing campaign links. I made it to help some ex-colleagues, and anyone who ever had to write those by hand knows the pain.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/url-inspector" rel="noopener noreferrer"&gt;URL Inspector&lt;/a&gt; is 549 lines and the only tool here that needs a server of mine. A browser can't read another site's headers directly, CORS blocks it, so a small proxy is the only way past that wall. Mine carries a per-IP rate limit, which is &lt;strong&gt;the exact thing I complained about at the top of this article&lt;/strong&gt;. The difference I'd claim is that it throttles repeat fetches of one URL through an open proxy rather than metering the person using it. That is also what every metered tool would say, so take it for what it's worth.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Math
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/unit-converter" rel="noopener noreferrer"&gt;Unit Converter&lt;/a&gt; covers length, weight, temperature, speed, area, volume, pressure, storage and number bases. With some funny ones in the mix.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/time-converter" rel="noopener noreferrer"&gt;Time Converter&lt;/a&gt; is mostly an &lt;code&gt;Intl&lt;/code&gt; handler. It updates Unix epoch time, ISO 8601 format and time zones in parallel, with a live clock.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  General purpose
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/what-is-my-ip" rel="noopener noreferrer"&gt;What Is My IP&lt;/a&gt; is 141 lines: it calls &lt;code&gt;ipapi.co/json/&lt;/code&gt; and formats the response. The browser deliberately hides your IP address from JavaScript, because any page that could read it could also fingerprint you with it. So the only way to show you your own address is to ask a server that can see where the request came from.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/weather" rel="noopener noreferrer"&gt;Weather&lt;/a&gt; joins a few services together into one worldwide weather map. It loads the mapping library Leaflet only when someone opens the tool, so it doesn't slow down every other page on the site.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The last 20%, or how I learned to love the process
&lt;/h2&gt;

&lt;p&gt;I've written &lt;a href="https://jlopes.eu/blog/ai-is-the-poisoned-apple-of-saas/" rel="noopener noreferrer"&gt;a bit about it&lt;/a&gt; before. There's a gap between a thing working and considering it ready for public. Oftentimes the last stretch of development (the last 20%) &lt;strong&gt;eats 80% of the development time&lt;/strong&gt;. If you develop only for yourself, you tend to stop once it does exactly what you want. But the moment you plan to hand a tool to anyone else, there are &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility" rel="noopener noreferrer"&gt;so&lt;/a&gt; &lt;a href="https://www.nngroup.com/articles/definition-user-experience/" rel="noopener noreferrer"&gt;many&lt;/a&gt; &lt;a href="https://www.w3.org/International/questions/qa-i18n" rel="noopener noreferrer"&gt;other&lt;/a&gt; &lt;a href="https://en.wikipedia.org/wiki/Software_testing" rel="noopener noreferrer"&gt;things&lt;/a&gt; to &lt;a href="https://medium.com/design-bootcamp/error-empty-states-the-unsung-heroes-of-ux-design-dcb99d99cc2b" rel="noopener noreferrer"&gt;consider&lt;/a&gt;.&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%2Fh3n032s1fk0rjpwgud1v.webp" 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%2Fh3n032s1fk0rjpwgud1v.webp" alt="A pie chart split 80 percent works on my machine and 20 percent works on yours, with the smaller slice covering most of the actual work if we want to launch a tool confidently for more people" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting from a proof-of-concept to something other people might actually want to use is such a high. Especially considering accessibility and usability. The unexpected issues and the extra requested features are opportunities for learning and experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go build things!
&lt;/h2&gt;

&lt;p&gt;Pick whatever service annoyed you recently with bad UX. Maybe the converter that wanted an account before it would touch your file. Make your own version, make it badly and improve on it. With AI or not (&lt;strong&gt;you do you, boo&lt;/strong&gt;). Break things, read documentation when they stay broken, learn as you go. And the next time a website stops you at three free conversions, you'll know exactly how small the wizard behind that curtain really is.&lt;/p&gt;

&lt;p&gt;Everything in my &lt;a href="https://github.com/offeringofpie/tools" rel="noopener noreferrer"&gt;repository&lt;/a&gt; is free to use and peruse, and if something inspired you to make more, even if just for yourself, that's awesome!&lt;/p&gt;




&lt;h2&gt;
  
  
  Questions people ask me about this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why do free online tools ask you to sign up?&lt;/strong&gt;&lt;br&gt;
The login is there to collect your email address, and sometimes the files themselves. It's a sales funnel, and that data is worth more to them than the tool costs to run. You end up being the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do browser-based tools need a backend?&lt;/strong&gt;&lt;br&gt;
Mostly not. Most of the tools I developed don't touch the network at all. It depends on the goal and complexity of the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do you need to build your own browser tool?&lt;/strong&gt;&lt;br&gt;
A text editor, a browser, and somewhere to put static files. The parts that do the heavy lifting are inside every browser and are documented for free on MDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you resize images without uploading them?&lt;/strong&gt;&lt;br&gt;
Yes, and there's no reason it would need uploading. The file stays on your machine, which saves you the upload wait and keeps a copy off someone else's disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you extract a colour palette from an image in JavaScript?&lt;/strong&gt;&lt;br&gt;
Sample pixels off the decoded image rather than reading all of them, convert those samples from RGB into a perceptually uniform space like Lab, then run k-means clustering to find the dominant groups. Clustering in raw RGB is the common shortcut and it produces muddy results, because equal distances in RGB don't look equally different to a human eye.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you render 30,000 characters without freezing the page?&lt;/strong&gt;&lt;br&gt;
Virtualise the grid so only the visible rows exist in the DOM, and cache the dataset in IndexedDB so it isn't re-downloaded on every visit. The rendering half of this is covered in detail in &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;Understanding List Virtualization&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>ai</category>
    </item>
    <item>
      <title>AI is the Poisoned Apple of the SaaS Ecosystem</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:42:54 +0000</pubDate>
      <link>https://dev.to/offeringofpie/ai-is-the-poisoned-apple-of-the-saas-ecosystem-1ef9</link>
      <guid>https://dev.to/offeringofpie/ai-is-the-poisoned-apple-of-the-saas-ecosystem-1ef9</guid>
      <description>&lt;p&gt;Companies replaced people with AI and are now quietly rehiring them. On why the last 20% of the work still needs humans, and what "good enough" is doing to SaaS.&lt;/p&gt;

&lt;p&gt;The promise of instant productivity has been reshaping the SaaS industry, leaving professional casualties behind. What looks like efficiency in a quarterly earnings call often turns out to be &lt;strong&gt;deferred cost&lt;/strong&gt;; in rehiring, in technical debt, or in customer trust, a year or two later.&lt;/p&gt;

&lt;p&gt;SaaS stands for &lt;strong&gt;software-as-a-service&lt;/strong&gt;, the rentier model where you subscribe to your favourite piece of software instead of buying it once. By ecosystem I mean that these services, more often than not, rely on each other, in a &lt;strong&gt;symbiotic relationship&lt;/strong&gt;. For example, a blog site may use a translation service, or a shop processes payments with an established vendor. But what happens when teams are given time and tokens to develop such services internally? A poisoned apple spoils and spreads through the bunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rehiring wave nobody wants to share
&lt;/h2&gt;

&lt;p&gt;Companies convinced themselves that AI could handle the judgment-heavy work their support and content teams did. "Why should we keep humans around when the model does some of the work?"&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%2Fcwiudyk7ucfhk7daa2kb.webp" 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%2Fcwiudyk7ucfhk7daa2kb.webp" alt="Why use many human when one A.I. do trick?" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Klarna &lt;a href="https://www.forbes.com/sites/quickerbettertech/2025/05/18/business-tech-news-klarna-reverses-on-ai-says-customers-like-talking-to-people/" rel="noopener noreferrer"&gt;stopped hiring&lt;/a&gt; for more than a year and cut staff by &lt;strong&gt;22%&lt;/strong&gt; after its AI chatbot handled &lt;strong&gt;2.3 million&lt;/strong&gt; conversations in 23 markets in a single month.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IBM replaced around &lt;a href="https://www.cnbc.com/2026/07/01/employers-who-laid-off-workers-for-ai-are-reversing-their-decisions.html" rel="noopener noreferrer"&gt;8,000 HR workers&lt;/a&gt; with its "Ask HR" AI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Commonwealth Bank of Australia &lt;a href="https://www.theregister.com/2025/08/22/commonwealth_ban_chatbot_fail_rehiring/" rel="noopener noreferrer"&gt;cut 45 customer service jobs&lt;/a&gt; claiming its AI voice bot had reduced call volumes by 2,000 a week. Calls rose instead, managers were drafted onto the phones, and the bank apologised and offered every job back.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the reversals started. &lt;a href="https://www.forrester.com/blogs/future-of-work-predictions-2026-whats-coming-for-work-and-the-workforce/" rel="noopener noreferrer"&gt;Forrester's 2026 Future of Work report&lt;/a&gt; found &lt;strong&gt;55%&lt;/strong&gt; of companies that laid off staff for AI &lt;a href="https://www.washingtontimes.com/news/2026/mar/10/ai-layoff-reversal-companies-rehire-customer-roles-eliminated/" rel="noopener noreferrer"&gt;now regret it&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Artificial intelligence is a fantastic tool, but it's only as good as the information you use to train it."&lt;br&gt;Charles Poon, Ford&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The case studies where lessons were learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Klarna&lt;/strong&gt; and &lt;strong&gt;Duolingo&lt;/strong&gt; saw real short-term gains. The problem is that they only measured what was easy to track. Klarna's assistant handled status updates and simple disputes well. Duolingo's GPT-4 system generated language exercises at a fraction of the cost of professional translators.&lt;/p&gt;

&lt;p&gt;Both CEOs publicly sold the AI-first story as a competitive edge. Then the quality gap showed the cracks in the system. Klarna's CEO Sebastian Siemiatkowski &lt;a href="https://www.vice.com/en/article/this-company-replaced-workers-with-ai-now-theyre-looking-for-humans-again/" rel="noopener noreferrer"&gt;acknowledged it directly&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Cost unfortunately seems to have been a too predominant evaluation factor... what you end up having is lower quality."&lt;br&gt;Sebastian Siemiatkowski, CEO of Klarna&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Klarna started rehiring people to handle the conversations &lt;a href="https://internative.net/insights/blog/klarna-ai-reversal-postmortem" rel="noopener noreferrer"&gt;the bot couldn't&lt;/a&gt;. Duolingo users noticed a drop in translation quality. When cancellations increased, the CEO doubled down with an AI-first message that drew hundreds of critical comments and later backtracked, saying he "did not give enough context".&lt;/p&gt;

&lt;p&gt;There's a footnote worth keeping just for the irony: Duolingo's founder, Luis von Ahn, was involved in inventing &lt;strong&gt;CAPTCHA&lt;/strong&gt;, built specifically to tell humans apart from machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adobe&lt;/strong&gt; spent a decade treating captive Creative Cloud subscribers as a stable revenue base, then leaned into generative AI by revising its terms of service in ways that appeared to grant it rights to train models on &lt;a href="https://helpx.adobe.com/firefly/web/get-started/learn-the-basics/adobe-firefly-faq.html" rel="noopener noreferrer"&gt;customer stock work&lt;/a&gt;, especially to customers that would sell on Adobe Stock. The clarification came too late to stop the perception that Adobe extracts from its userbase rather than serve it. Instead of competing on product quality, it tried to buy its way past Figma for &lt;a href="https://www.theguardian.com/technology/2023/dec/18/adobe-drops-20bn-takeover-of-figma-after-eu-and-uk-regulator-concerns" rel="noopener noreferrer"&gt;$20 billion&lt;/a&gt;, lost the deal to EU and UK regulators along with a &lt;a href="https://www.theregister.com/software/2023/12/18/adobe-drops-bid-to-buy-figma-amid-monopoly-concerns/775870" rel="noopener noreferrer"&gt;$1 billion breakup fee&lt;/a&gt;, and was left with no competitive design tool. Adobe's stock &lt;a href="https://finance.yahoo.com/news/adobe-stock-drops-26-wall-112203636.html" rel="noopener noreferrer"&gt;has lost roughly two-thirds of its value&lt;/a&gt; since. Locking users into a subscription while using AI to cut costs rather than improve the product works only until your audience feels alienated.&lt;/p&gt;

&lt;p&gt;Not every company felt an ominous boomerang, however. At &lt;strong&gt;Shopify&lt;/strong&gt;, AI now &lt;a href="https://www.bnnbloomberg.ca/business/2026/05/05/shopify-leaning-deeper-into-ai-reports-us581m-q1-loss/" rel="noopener noreferrer"&gt;writes more than half of the company's internal code&lt;/a&gt;. Their revenue grew &lt;strong&gt;34%&lt;/strong&gt; in Q1 2026 while headcount declined, pushing revenue per employee to &lt;a href="https://aiadopters.club/p/shopify-grew-30-percent-with-500" rel="noopener noreferrer"&gt;around $1.63 million&lt;/a&gt;. Still, even Shopify's own reporting calls the productivity evidence "&lt;a href="https://finance.yahoo.com/markets/stocks/articles/shopify-shop-backs-management-2026-011101150.html" rel="noopener noreferrer"&gt;genuinely mixed&lt;/a&gt;," leaving the approach entirely at management's discretion. It's less proof that AI-driven choices are risk-free, and more a case of preparation beating panic.&lt;/p&gt;

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

&lt;p&gt;AI tools produce decent first drafts fast. For managers with access but limited hands-on experience, "80% done" feels like a real handoff rather than a rough start. The team receiving the work hears "AI did the heavy lifting, just clean it up", which makes the remaining work sound like polishing. To no one's surprise, it isn't.&lt;/p&gt;

&lt;p&gt;The 80/20 rule, or Pareto Principle, is usually cited as "80% of value comes from 20% of effort." In software, the mirror version matters more: &lt;a href="https://blog.exceeds.ai/80-20-rule-software-teams/" rel="noopener noreferrer"&gt;the last 20% of a feature takes 80% of the time&lt;/a&gt;. That's when the polishing phase of a project begins. Defining error handling, edge cases, accessibility, security audits, and unknown unknowns that may cause breaks in production.&lt;/p&gt;

&lt;p&gt;Research published &lt;a href="https://thevibelog.dev/blog/vibe-coding-technical-debt-2026/" rel="noopener noreferrer"&gt;in early 2026&lt;/a&gt; found that after teams adopted AI coding tools extensively, code duplication rose by &lt;strong&gt;48%&lt;/strong&gt;, refactoring decreased by &lt;strong&gt;60%&lt;/strong&gt;, and technical debt increased by &lt;strong&gt;~41%&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The flow-debt trade-off arises when seamless code generation occurs, leading to the accumulation of technical debt through architectural inconsistencies, security vulnerabilities, and increased maintenance overhead."&lt;br&gt;&lt;a href="https://arxiv.org/abs/2512.11922" rel="noopener noreferrer"&gt;Flow, Technical Debt, and Guidelines for Sustainable Use&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&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%2Fup8z2xkd818hmgmw06x3.webp" 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%2Fup8z2xkd818hmgmw06x3.webp" alt="Rest in peace, SaaS..." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The death of SaaS
&lt;/h2&gt;

&lt;p&gt;A friend who works in SaaS put it well: there's growing internal pressure to build tools in-house rather than buy them, because a capable-enough AI model removes the need to shop for a specialised SaaS product.&lt;/p&gt;

&lt;p&gt;Some examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An automated translation layer for multilingual publishing, instead of paying a subscription for an internationalisation service.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A homegrown content management system to bridge the gap between sales and marketing, instead of finding a suitable platform.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An internal project management tool rather than a licensed one.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these replacements is as good as the dedicated products they're displacing. But they're &lt;strong&gt;"good enough"&lt;/strong&gt;, and good enough is cheaper than a recurring bill, once a team has Claude Code and a few days to spare.&lt;/p&gt;

&lt;p&gt;A SaaS is a customer too: it pays for hosting, analytics, a payment provider. Every cancelled subscription starves a vendor, which then cancels subscriptions of its own, and the web gets a little thinner. This isn't hypothetical: a third of enterprises have already replaced at least one SaaS tool &lt;a href="https://retool.com/blog/ai-build-vs-buy-report-2026" rel="noopener noreferrer"&gt;with something built in-house&lt;/a&gt;, and Sanofi is &lt;a href="https://www.pymnts.com/news/artificial-intelligence/2026/smbs-swap-pricey-saas-contracts-for-ai-built-apps/" rel="noopener noreferrer"&gt;cutting &lt;strong&gt;80%&lt;/strong&gt; of its ServiceNow usage&lt;/a&gt; with agents built on Claude Code. Meanwhile the homegrown replacements rot the way anything built to 80% rots, with no vendor left to fix them and, in time, no product left to crawl back to. The money that used to feed thousands of vendors now feeds Anthropic, OpenAI, and Microsoft. The line goes up, over the corpses of many decent products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Line goes up
&lt;/h2&gt;

&lt;p&gt;LLM "labs" (Anthropic, Google, OpenAI, and the rest) need ongoing investment, attention, and the promise that the next breakthrough is close. The claims have been consistent: Sam Altman's June 2025 essay "&lt;a href="https://velmoy.com/en/pursuit/ai/altman-takeoff-begonnen" rel="noopener noreferrer"&gt;The Gentle Singularity&lt;/a&gt;" declared the AGI takeoff was already underway. Dario Amodei wrote that "exceptionally powerful AI" could &lt;a href="https://techcrunch.com/2025/03/19/the-ai-leaders-bringing-the-agi-debate-down-to-earth/" rel="noopener noreferrer"&gt;arrive as soon as 2026&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Smarter than a Nobel Prize winner across most relevant fields."&lt;br&gt;Dario Amodei, CEO of Anthropic&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The benchmarks tell a quieter story. Each new AI model is only getting a little better at standard tests, moving from &lt;strong&gt;86%&lt;/strong&gt; to &lt;strong&gt;~90%&lt;/strong&gt; correct on &lt;a href="https://mixflow.ai/blog/ai-plateau-expert-analysis-on-large-language-model-performance-in-2025/" rel="noopener noreferrer"&gt;benchmark tests for coding and knowledge&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An arXiv paper titled "&lt;a href="https://arxiv.org/pdf/2512.20264.pdf" rel="noopener noreferrer"&gt;The AI Scaling Wall of Diminishing Returns&lt;/a&gt;" laid out the mathematical case. A January 2026 MIT paper on &lt;a href="https://ide.mit.edu/wp-content/uploads/2026/01/Meek_Models_Jan2026.pdf" rel="noopener noreferrer"&gt;low-budget models&lt;/a&gt; found that exponentially increasing inference compute produces diminishing benchmark gains.&lt;/p&gt;

&lt;p&gt;Yann LeCun, a Turing Award winner and one of the fathers of deep learning, called LLMs "&lt;a href="https://velmoy.com/en/pursuit/ai/altman-takeoff-begonnen" rel="noopener noreferrer"&gt;a dead end&lt;/a&gt;" architecturally. Thomas Wolf of Hugging Face pointed out that Nobel-level breakthroughs come from asking new questions, &lt;a href="https://techcrunch.com/2025/03/19/the-ai-leaders-bringing-the-agi-debate-down-to-earth/" rel="noopener noreferrer"&gt;not answering known ones&lt;/a&gt;, which is what current models are trained to do. Sam Altman has since quietly admitted that &lt;a href="https://velmoy.com/en/pursuit/ai/altman-takeoff-begonnen" rel="noopener noreferrer"&gt;a new architecture is needed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The AGI deadline has moved so many times that it reminds me of the decades-old promised theory of everything, pushed by string theorists to mould known mathematics and physics theories to make their model work. It's a noble cause, but it ends up taking funding from other theories, due to its sheer popularity. It's sexy, it sells.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to a local model
&lt;/h2&gt;

&lt;p&gt;The move toward AI that runs on your own phone or laptop, instead of some distant server, tells you something. The cloud-everything approach isn't working, not financially and not technically. Running AI at scale in remote data centres is expensive. Waiting on a response from a far-away server slows things down for whoever's using the product. And if your product always depends on someone else's servers, you don't really own the edge over competitors. You're just renting it.&lt;/p&gt;

&lt;p&gt;At WWDC 2025, Apple shipped an AI model that &lt;a href="https://machinelearning.apple.com/research/apple-foundation-models-tech-report-2025" rel="noopener noreferrer"&gt;runs entirely on your iPhone&lt;/a&gt;, with no external server needed. That was only possible because Apple has spent 15 years building its own chips &lt;a href="https://appleinsider.com/articles/26/05/28/apple-doubling-down-on-on-device-ai-at-wwdc-2026" rel="noopener noreferrer"&gt;specifically for this kind of task&lt;/a&gt;. In 2026, it went further, running a significantly &lt;a href="https://www.macrumors.com/2026/05/28/apple-to-make-on-device-ai-key-focus/" rel="noopener noreferrer"&gt;more powerful model locally&lt;/a&gt; on the iPhone 17 Pro and newer Macs.&lt;/p&gt;

&lt;p&gt;NVIDIA is doing something similar for PCs. Its &lt;a href="https://blogs.nvidia.com/blog/rtx-ai-garage-gtc-2026-nemoclaw/" rel="noopener noreferrer"&gt;DGX Spark&lt;/a&gt;, a small desktop machine, can run a &lt;strong&gt;120-billion-parameter&lt;/strong&gt; model entirely offline. Qualcomm and Samsung are moving in the same direction on mobile chips.&lt;/p&gt;

&lt;p&gt;And while American labs sell subscriptions, Chinese labs (DeepSeek, Qwen, Kimi, and GLM) give the models away under MIT or Apache licences, allowing you to run them on your machine. Qwen alone was downloaded &lt;a href="https://www.opensourceforu.com/2026/07/alibabas-qwen-crosses-one-billion-downloads-eclipsing-metas-llama/" rel="noopener noreferrer"&gt;over &lt;strong&gt;one billion&lt;/strong&gt; times&lt;/a&gt; and overtook Meta's Llama as the most used open model family.&lt;/p&gt;

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

&lt;p&gt;AI genuinely helps with first drafts, summarising, routing simple requests, running specialised skills like UX and accessibility analysis, and catching basic bugs. It's not good enough for &lt;strong&gt;the last 20%&lt;/strong&gt; of any feature. Hell, hardly for the first 80%. That's what we're here for.&lt;/p&gt;

&lt;p&gt;The companies rehiring laid-off staff aren't giving up on AI. They're &lt;a href="https://www.cnbc.com/2026/07/01/employers-who-laid-off-workers-for-ai-are-reversing-their-decisions.html" rel="noopener noreferrer"&gt;recalibrating it as a helper&lt;/a&gt; rather than a replacement, as serious practitioners said before the cost-cutting trend took over.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"AI is more than up to the task. What these companies discovered is that &lt;em&gt;they&lt;/em&gt; weren't. They skipped the hard part."&lt;br&gt;&lt;a href="https://www.washingtontimes.com/news/2026/mar/10/ai-layoff-reversal-companies-rehire-customer-roles-eliminated/" rel="noopener noreferrer"&gt;Eric Vaughan, IgniteTech&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Maybe it's time to invite back the golden apples, the people who actually dedicated their careers to the last 20%.&lt;/p&gt;

&lt;p&gt;There is comfort in the chaos of human craft.&lt;/p&gt;

&lt;p&gt;Hail Eris.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jlopes.eu/blog/ai-is-the-poisoned-apple-of-saas/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Understanding List Virtualization</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:37:02 +0000</pubDate>
      <link>https://dev.to/offeringofpie/understanding-list-virtualization-54fk</link>
      <guid>https://dev.to/offeringofpie/understanding-list-virtualization-54fk</guid>
      <description>&lt;p&gt;If you don't know what &lt;a href="https://www.patterns.dev/vanilla/virtual-lists/" rel="noopener noreferrer"&gt;virtualization&lt;/a&gt; is, you probably don't need it.&lt;/p&gt;

&lt;p&gt;It essentially renders only the items that need to be visible in the UI. Most lists are short, most grids are small, and the browser renders them without complaint.&lt;/p&gt;

&lt;p&gt;I didn't need it either, until I decided to put the entire Unicode table on a page.&lt;/p&gt;

&lt;p&gt;The whole idea fits in one running component: ten thousand cells in a grid, with only the visible handful in the DOM. There's a live demo of it in &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;the original post on jlopes.eu&lt;/a&gt;; scroll it, then flip to the code tab.&lt;/p&gt;

&lt;p&gt;A quick word on what "in the DOM" means, because the rest of this article hangs on it. The DOM is the browser's live model of the page: every div, button, and span is a node in one big tree, and the browser has to lay each node out, paint it, and keep it in memory. A few hundred nodes are nothing. Tens of thousands are a problem, and the problem compounds when they keep changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice, the Character map
&lt;/h2&gt;

&lt;p&gt;I built a tool called &lt;a href="https://jlopes.eu/tools/character-map" rel="noopener noreferrer"&gt;Character map&lt;/a&gt;. It scans over 30,000 code points (Unicode's term for one character), loaded once and cached in the browser's built-in database IndexedDB, so the table doesn't re-download on every visit. You browse it through a grid of tiles. The filtered results are capped at 2,000, and I assumed that ceiling would keep it smooth.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;**Narrator&lt;/em&gt;&lt;em&gt;: It didn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first version rendered every result, so a single filter pass produced 2,000 buttons. Scrolling stuttered, and resizing was worse because the browser had to recalculate the position of every tile in the grid.&lt;/p&gt;

&lt;p&gt;Search is debounced at 150ms, meaning it waits until you've paused typing for 150ms before running the filter, and even so, each pause re-ran the filter, threw away the grid, and rebuilt it.&lt;/p&gt;

&lt;p&gt;Memory climbed while I typed. Two thousand rows are nothing to a database, but two thousand live elements in a reflowing grid were plenty to kill page performance.&lt;/p&gt;

&lt;p&gt;It took me a while to realise that there were two costs involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One is the number of DOM nodes.&lt;/li&gt;
&lt;li&gt;The other is how often the existing nodes rebuild.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Virtualization fixes the first and, on its own, does nothing about the second. We'll get to the second thing later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lying to the scrollbar
&lt;/h2&gt;

&lt;p&gt;At any moment, the viewport (the visible area of the scroll container) shows a few rows. Everything above and below is off-screen and, as far as the user can tell, doesn't need to exist. So you keep the visible rows in the DOM plus a small buffer, and drop the rest. As you scroll, the top and bottom rows are mounted/unmounted depending on the direction. The user sees a full list, and the browser only cares about a few rows.&lt;/p&gt;

&lt;p&gt;The catch is the scrollbar. A scrollbar's length is as big as the content, so if only twenty rows exist, you reach the bottom and ruin the effect. It has to keep lying, or the whole effect feels broken. The lie is a single spacer element that wraps the existing rows, sized to the height the rows would occupy if they all existed at the same time. The rows you mount are placed on top of that spacer with a &lt;code&gt;translateY&lt;/code&gt; offset, shifting elements down by a few pixels without breaking the layout.&lt;/p&gt;

&lt;p&gt;Which rows to mount now will depend on arithmetic. You always know the scroll position and the row height, so dividing one by the other tells you which row index sits at the top edge of the viewport; add the viewport height, and you have the bottom edge. React &lt;a href="https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists" rel="noopener noreferrer"&gt;called this windowing&lt;/a&gt;, and &lt;a href="https://web.dev/articles/virtualize-long-lists-react-window" rel="noopener noreferrer"&gt;web.dev walks through the same pattern with react-window&lt;/a&gt;. The mechanics are identical in Vue.&lt;/p&gt;

&lt;p&gt;Stripped of framework, the whole geometry is three numbers:&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;spacerHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rowCount&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startRow&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endRow&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;ceil&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;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;)&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;span class="c1"&gt;// each mounted row sits at translateY(rowIndex * rowHeight)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the rows from &lt;code&gt;startRow&lt;/code&gt; to &lt;code&gt;endRow&lt;/code&gt; exist. Everything the scrollbar believes about the rest of the content comes from the spacer. And the whole trick is the division. For example: rows are 80px tall, you've scrolled 800px, so row 10 sits at the top edge. Nothing gets searched or measured.&lt;/p&gt;

&lt;p&gt;The payoff is that the DOM node count stops tracking the dataset. Twenty rows on screen means twenty-odd rows in the DOM, whether the list holds two thousand entries or two hundred thousand. As far as the layout engine is concerned, the other 199,980 rows are a myth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grid is a list of rows in a trench coat
&lt;/h2&gt;

&lt;p&gt;This is where my grid diverged from the tutorials. A &lt;a href="https://tanstack.com/virtual/latest/docs/introduction" rel="noopener noreferrer"&gt;virtualiser&lt;/a&gt;, the library that handles the windowing bookkeeping for you, thinks in lists: one item per index, stacked vertically, each taking the full width. My tiles sit twelve to a row. Hand the virtualiser the flat array of tiles, and it will earnestly stack 2,000 full-width rows of one glyph each, and every offset it calculates is wrong.&lt;/p&gt;

&lt;p&gt;The fix is to change what an item means. Slice the flat list into groups of twelve first, and make each group the virtual item. The virtualiser counts and positions rows, never knowing whether they contain cells; a plain CSS grid lays the tiles out inside each one.&lt;/p&gt;

&lt;p&gt;The demo in the original post does all of this by hand: reads &lt;code&gt;scrollTop&lt;/code&gt;, divides by the row height, and mounts the window. For the Character map, I used &lt;a href="https://tanstack.com/virtual/latest" rel="noopener noreferrer"&gt;TanStack&lt;/a&gt;'s &lt;code&gt;useVirtualizer&lt;/code&gt; instead, purely for simplicity's sake: someone has to own the scroll listener, the range maths, and the offsets, and I'd rather it wasn't me.&lt;/p&gt;

&lt;p&gt;In code, that's one computed and one hook. A computed is Vue's memoised derived value; it recalculates only when something changes. &lt;code&gt;rows&lt;/code&gt; does the slicing, and the virtualiser gets told three things: how many rows there are, which element scrolls, and that each row is about 80px tall. Trimmed from the Character map:&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;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Char&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="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;filteredChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;out&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;filteredChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;columns&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;out&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;virtualGrid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useVirtualizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;getScrollElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;estimateSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template renders only the visible rows, each with its own grid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"grid"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"overflow-y-auto h-[60vh] min-h-80"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ height: `${virtualGrid.getTotalSize()}px`, position: 'relative' }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
        &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"row in virtualGrid.getVirtualItems()"&lt;/span&gt;
        &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"row.key"&lt;/span&gt;
        &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-3 lg:grid-cols-12 absolute w-full"&lt;/span&gt;
        &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ height: `${row.size}px`, transform: `translateY(${row.start}px)` }"&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"c in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"c.cp"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"openDetails(c)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          {{ c.char }}
        &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details hide in that setup. &lt;code&gt;estimateSize: () =&amp;gt; 80&lt;/code&gt; is a promise that every row is 80px tall, so the virtualiser never has to measure anything. And in the real component &lt;code&gt;columns&lt;/code&gt; isn't a constant: it's its own computed fed by a breakpoint helper, 12 on desktop and 3 on mobile...&lt;/p&gt;

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

&lt;p&gt;Overscan is the number of extra rows past the visible edge that stay mounted, a buffer above and below the window. In the window math from earlier, it widens both edges before they're clamped to the ends of the list:&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;startRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;overscan&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;endRow&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&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;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;)&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;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;overscan&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;CharacterMap keeps 5, and I found that number by getting it wrong first. At 1 or 2, a fast flick on a phone flashed a blank space at the leading edge, so for a frame or two, there was simply nothing there. Raising it gave the browser rows in reserve, but I took care to make sure I wasn't causing slowness by trying to keep the illusion. I added and removed until there was no "blank" issue for my scrolling speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Later, getting to the second thing
&lt;/h2&gt;

&lt;p&gt;Windowing fixes node count. It does nothing about how often the surviving rows re-render, and a virtualised grid re-renders a lot. Every scroll frame moves the window. If each visible row re-renders every time, we're just putting a tiny band-aid on a big gash. Both React and Vue tackle the issue differently: how does a row skip re-rendering when nothing about it has changed?&lt;/p&gt;

&lt;p&gt;React re-renders by re-running the component and diffing the result against the last one. Normally when a component re-renders, its children re-render too, whether their inputs changed or not. In a virtualised grid, that means a scroll-driven state change can re-invoke every mounted row. React's way out is to mark the components that can skip updates by wrapping them in &lt;a href="https://react.dev/reference/react/memo#skipping-re-rendering-when-props-are-unchanged" rel="noopener noreferrer"&gt;&lt;code&gt;memo()&lt;/code&gt;&lt;/a&gt;, comparing each prop with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is" rel="noopener noreferrer"&gt;&lt;code&gt;Object.is&lt;/code&gt;&lt;/a&gt;. This method compares references, and for example two objects with identical contents are still two different objects. In JavaScript, &lt;code&gt;{} === {}&lt;/code&gt; is false. &lt;code&gt;memo()&lt;/code&gt; compares against the last rendered version, and if it sees a different reference, the row is re-rendered. &lt;a href="https://react.dev/reference/react/useMemo" rel="noopener noreferrer"&gt;&lt;code&gt;useMemo&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://react.dev/reference/react/useCallback" rel="noopener noreferrer"&gt;&lt;code&gt;useCallback&lt;/code&gt;&lt;/a&gt; exist for exactly this, keeping references stable across renders, so &lt;code&gt;memo&lt;/code&gt; has something equal to find.&lt;/p&gt;

&lt;p&gt;In row form:&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;const&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memo&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;Row&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onOpen&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;RowProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"row"&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;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onClick&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;char&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;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;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;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// no memo: new in every render, props are never equal&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&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="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpen&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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="c1"&gt;// memo: the reference is the same one memo saw last time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openDetails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&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;Row&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&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="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openDetails&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;Vue starts from the other side. A parent updating doesn't drag its children along unless their props changed too. The compiler adds its own layer, knowing which parts of a template can change and which never will. Much of what &lt;code&gt;memo&lt;/code&gt; does in React is the default here. When you do want manual control, &lt;a href="https://vuejs.org/api/built-in-directives.html#v-memo" rel="noopener noreferrer"&gt;&lt;code&gt;v-memo&lt;/code&gt;&lt;/a&gt; freezes a piece of the template until the values you pick change, and &lt;code&gt;computed&lt;/code&gt; covers the derived state.&lt;/p&gt;

&lt;p&gt;In the Character map, this shows up as a chain of &lt;code&gt;computed()&lt;/code&gt;, each link rerunning only when the one before it changes:&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;filteredChars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;characters&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;activeGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;activeGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&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;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cm"&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;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxResults&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;rows&lt;/code&gt; recomputes only when &lt;code&gt;filteredChars&lt;/code&gt; or &lt;code&gt;columns&lt;/code&gt; change, and the virtualiser recounts only when &lt;code&gt;rows&lt;/code&gt; changes. Scrolling does not affect that chain. It moves the window and leaves the mounted rows alone.&lt;/p&gt;

&lt;p&gt;Both frameworks can reach the same end, but React makes you opt out of re-rendering and Vue makes you opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to break the illusion
&lt;/h2&gt;

&lt;p&gt;Two more things bit me, and neither is related to React or Vue:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The container needs a real height. The fix for me was a fixed height on the scroll container (&lt;code&gt;60vh&lt;/code&gt;) plus &lt;code&gt;overflow-y-auto&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keys have to carry an identity. With a bad key, nodes would get recycled whenever the cell props changed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My first version keyed tiles by array index, and this didn't give great results... it would re-filter on every keystroke and toggle whenever the node in slot 0 had an id of 0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- index: look at me, I'm the index 0 now --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"(c, i) in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ c.char }}&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- identity: oh, hi mark! --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"c in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"c.cp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ c.char }}&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With each node bound to its own datum, the nodes stopped rerendering. Index keys look fine right up until the previous item moves away, and in a search UI, items tend to move constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: Icon Search
&lt;/h2&gt;

&lt;p&gt;All of this got a second run. &lt;a href="https://jlopes.eu/tools/icon-search/" rel="noopener noreferrer"&gt;Icon Search&lt;/a&gt; merges seven icon libraries (Heroicons, Lucide, Phosphor, Solar, Font Awesome, Carbon, Simple Icons) into one searchable set that runs into the tens of thousands.&lt;br&gt;
The tiles now have an inline SVG instead of a text glyph. But with the learnings from the previous tool, the pattern used was nearly untouched. Filtered results are capped at 2,000, chunked into rows, and virtualised by row.&lt;/p&gt;

&lt;p&gt;The biggest change was row height: IconSearch tiles carry a label that can wrap to a second line, so rows aren't a uniform height anymore. That matters more than it sounds, because every row's offset is the sum of the heights of all the rows above it. Dynamic height is a two-step process rather than a smarter estimate. &lt;code&gt;estimateSize&lt;/code&gt; still runs first, and its guess (88px here) positions rows on the initial render. Then, as each row mounts, a ref callback hands the node to the virtualiser's &lt;a href="https://tanstack.com/virtual/latest/docs/api/virtualizer#measureelement" rel="noopener noreferrer"&gt;&lt;code&gt;measureElement&lt;/code&gt;&lt;/a&gt;, which reads the real rendered height via &lt;code&gt;getBoundingClientRect()&lt;/code&gt; and replaces the cached estimate. The &lt;code&gt;data-index&lt;/code&gt; attribute tells it which row the node belongs to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"row in rowVirtualizer.getVirtualItems()"&lt;/span&gt;
  &lt;span class="na"&gt;:ref=&lt;/span&gt;&lt;span class="s"&gt;"(el) =&amp;gt; rowVirtualizer.measureElement(el as Element)"&lt;/span&gt;
  &lt;span class="na"&gt;:data-index=&lt;/span&gt;&lt;span class="s"&gt;"row.index"&lt;/span&gt;
  &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-4 lg:grid-cols-12 absolute w-full"&lt;/span&gt;
  &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ transform: `translateY(${row.start}px)` }"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- tiles for rows[row.index] --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;In cases like this, it's best to window the DOM so node count stops tracking the dataset. And then slice the grid into rows so the window has something list-shaped to travel over. Tune overscan by getting it wrong in both directions and settling in the middle. And keep the mounted rows idle when nothing about them has changed: React makes you opt out of re-rendering, Vue mostly handles it by default.&lt;/p&gt;

&lt;p&gt;Both tools are open source: &lt;a href="https://github.com/offeringofpie/tools/blob/main/app/components/global/CharacterMap.vue" rel="noopener noreferrer"&gt;CharacterMap.vue&lt;/a&gt; and &lt;a href="https://github.com/offeringofpie/tools/blob/main/app/components/global/IconSearch.vue" rel="noopener noreferrer"&gt;IconSearch.vue&lt;/a&gt;. This article was originally published at &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;, where the interactive demo lives.&lt;/p&gt;

</description>
      <category>react</category>
      <category>vue</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
