<?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: Oskar Szulc</title>
    <description>The latest articles on DEV Community by Oskar Szulc (@oskarszulc).</description>
    <link>https://dev.to/oskarszulc</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%2F135630%2F53391d20-ee38-46e5-ab84-2234f527e570.jpg</url>
      <title>DEV Community: Oskar Szulc</title>
      <link>https://dev.to/oskarszulc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oskarszulc"/>
    <language>en</language>
    <item>
      <title>Game dev techniques that made our Vue web app 20x faster</title>
      <dc:creator>Oskar Szulc</dc:creator>
      <pubDate>Thu, 15 Oct 2026 19:41:50 +0000</pubDate>
      <link>https://dev.to/oskarszulc/game-dev-techniques-that-made-our-vue-web-app-20x-faster-4l30</link>
      <guid>https://dev.to/oskarszulc/game-dev-techniques-that-made-our-vue-web-app-20x-faster-4l30</guid>
      <description>&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; Five game engine techniques fixed our Vue org chart once customers reached 240k employees: frustum culling (render only what's on screen), spatial partitioning, moving heavy work into Web Workers, level of detail, and hash map lookups. Rendering got 20x faster (a 10k org went from 1 min 45 s to under 5 seconds), and filters went from 9 seconds to 1.5.&lt;/p&gt;

&lt;p&gt;Before web dev I made video games for about 5 years. Unity, C#, real-time multiplayer, frame budgets. When I started building org chart visualization software I didn’t think that background would be so relevant in performance optimisation front. Then our customers hit 10k, 30k, 240k employees and I was solving the same problems – just with DOM nodes instead of sprites and entities.&lt;/p&gt;

&lt;p&gt;Here are specific techniques from game dev that made the biggest difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don’t process what’s off-screen
&lt;/h2&gt;

&lt;p&gt;Most fundamental optimization in games. If something isn’t visible to the player, skip it entirely. No rendering, no physics, minimal AI. Games call this frustum culling.&lt;/p&gt;

&lt;p&gt;Our org chart is an infinite canvas. At any moment maybe 50-100 cards are visible, but the full tree might have 10,000+ nodes.&lt;/p&gt;

&lt;p&gt;What we had before: render all 10k nodes, let the browser figure out visibility. 10,000 sets of DOM elements, Vue instances, reactive watchers – all for off-screen nodes.&lt;/p&gt;

&lt;p&gt;What I did instead: bare-bones HTML placeholders for nodes outside the viewport. No Vue component mounted, no reactive tracking. When a node enters the viewport during panning, it gets instantiated on the fly. Vue’s async components made this clean.&lt;/p&gt;

&lt;p&gt;For 10k nodes: ~100 fully rendered + 9,900 lightweight placeholders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; 10k org → 1 min 45 sec, 5.5 GB&lt;br&gt;&lt;br&gt;
&lt;strong&gt;After:&lt;/strong&gt; same org → under 5 seconds, 1.1 GB&lt;/p&gt;
&lt;h2&gt;
  
  
  Grid-based spatial partitioning
&lt;/h2&gt;

&lt;p&gt;The viewport culling above has a problem. To decide which nodes are visible you check each one against viewport boundaries. 10k nodes × 60fps = 600k checks per second. Panning felt choppy.&lt;/p&gt;

&lt;p&gt;Games solved this with spatial partitioning. Divide the world into a grid, each cell ~500px. To find visible objects, check which grid cells overlap the viewport — much fewer operations — then tell nodes in those cells they might be on screen.&lt;/p&gt;

&lt;p&gt;Scales regardless of total node count. Whether 10k or 100k nodes, the number of grid cells overlapping the viewport stays roughly constant. Same thing every RTS does — StarCraft, Age of Empires, all of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Frame budgets and the render thread
&lt;/h2&gt;

&lt;p&gt;In games you have 16.67ms per frame at 60fps. Blow that and the game stutters. Web browsers have the same constraint — block the main thread for 200ms and the UI freezes.&lt;/p&gt;

&lt;p&gt;Our API sends compressed JSON. For 30k employees, decompression takes ~1.5 seconds on M1 Pro. That’s 1.5 seconds of frozen UI — no spinner, no animations, nothing.&lt;/p&gt;

&lt;p&gt;In games the answer is obvious: move heavy work off the main thread. I moved JSON decompression to a Web Worker. Still takes 1.5 seconds, but loading animations play smoothly and the browser never shows “not responding.”&lt;/p&gt;

&lt;p&gt;This also unblocked TanStack Query for background refresh — previously even background refetches caused micro-freezes during decompression.&lt;/p&gt;
&lt;h2&gt;
  
  
  The dictionary lookup pattern
&lt;/h2&gt;

&lt;p&gt;Less “game dev technique” and more “thing anyone doing real-time systems learns the hard way.”&lt;/p&gt;

&lt;p&gt;In games, looking up an entity by ID happens millions of times per frame. You never iterate an array to find something.&lt;/p&gt;

&lt;p&gt;What we had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// O(n*m) — 10k updates against 20k array = 200M comparisons&lt;/span&gt;
&lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;personId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;personId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;people&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;person&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// O(n+m) — build lookup once&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;indexById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;indexById&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;personId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;indexById&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;personId&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;index&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;people&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;person&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found a production bug where &lt;code&gt;.includes()&lt;/code&gt; on an array was called inside a filter loop for 20k employees. Browser ran out of memory. Dictionary lookup fixed it instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;None of these techniques are new. Any Unity or Unreal developer would recognize them. But they’re rarely discussed in web dev because most apps don’t push the browser hard enough.&lt;/p&gt;

&lt;p&gt;The moment your app becomes an infinite canvas — org charts, maps, design tools, data visualizations — you’re building something closer to a game engine than a web app. The constraints are the same: limited frame budget, finite memory, large worlds with smooth interaction.&lt;/p&gt;

&lt;p&gt;I’d recommend looking at game dev resources before web performance blogs if you’re in that space. The game dev community has had 30+ years to figure these problems out.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Web application&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frustum culling&lt;/td&gt;
&lt;td&gt;Viewport-based instantiation&lt;/td&gt;
&lt;td&gt;20x render speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spatial partitioning&lt;/td&gt;
&lt;td&gt;Grid-based intersection&lt;/td&gt;
&lt;td&gt;Smooth panning at any scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thread offloading&lt;/td&gt;
&lt;td&gt;Web Workers&lt;/td&gt;
&lt;td&gt;UI never freezes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Level of detail&lt;/td&gt;
&lt;td&gt;Simplified zoomed-out components&lt;/td&gt;
&lt;td&gt;10x fewer reactive bindings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash map lookups&lt;/td&gt;
&lt;td&gt;Dictionary-based data access&lt;/td&gt;
&lt;td&gt;Filters from 9s to 1.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://oskarszulc.com/game-dev-techniques-that-made-our-vue-web-app-20x-faster/" rel="noopener noreferrer"&gt;oskarszulc.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vue</category>
      <category>performance</category>
      <category>gamedev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Making a 240k-record org chart load in a browser</title>
      <dc:creator>Oskar Szulc</dc:creator>
      <pubDate>Thu, 15 Oct 2026 19:41:22 +0000</pubDate>
      <link>https://dev.to/oskarszulc/making-a-240k-record-org-chart-load-in-a-browser-21ag</link>
      <guid>https://dev.to/oskarszulc/making-a-240k-record-org-chart-load-in-a-browser-21ag</guid>
      <description>&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; Our org chart froze the browser at 10k employees (1 min 45 s to expand, 5.5 GB of memory) and crashed outright at 240k. Over two years I replaced per-node Vue instances with a ~200-line renderer, rendered only what's in the viewport, and moved hierarchy building to the backend. A 10k org now renders in under 5 seconds with 1.1 GB, and a 240k org loads in 0.8 GB.&lt;/p&gt;

&lt;p&gt;At Agentnoon we build org chart software for large enterprises. Our customers range from 5,000 to 240,000 employees. This is what I learned scaling the frontend over ~2 years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we started
&lt;/h2&gt;

&lt;p&gt;Mid-2022, I integrated d3-org-chart as the foundation. It handled layout, expand/collapse, zooming. We mounted small Vue instances as custom elements inside each node. It was a necessary workaround step since d3 only supported string templates as the individual elements, so tiny custom html elements were our approach necessary for this implementation.&lt;/p&gt;

&lt;p&gt;This worked fine for a few thousand employees. At 20k it got sluggish. At 240k the browser just died.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;For every org chart node we were:&lt;br&gt;&lt;br&gt;
– creating a custom element via &lt;code&gt;document.createElement&lt;/code&gt;&lt;br&gt;&lt;br&gt;
– mounting a fresh Vue instance inside it&lt;br&gt;&lt;br&gt;
– setting up reactive dependency tracking&lt;br&gt;&lt;br&gt;
– rendering the component in its own scope, disconnected from the parent app&lt;/p&gt;

&lt;p&gt;With 10k visible nodes:&lt;br&gt;&lt;br&gt;
– &lt;strong&gt;1 min 45 sec&lt;/strong&gt; to expand the full chart&lt;br&gt;&lt;br&gt;
– &lt;strong&gt;5.5 GB&lt;/strong&gt; memory&lt;br&gt;&lt;br&gt;
– browser frozen&lt;/p&gt;

&lt;p&gt;The d3-org-chart library was 2,000+ lines of code we’d heavily patched. Drag &amp;amp; drop libraries couldn’t understand the shadow DOM scope. Every new feature was duct tape on top of duct tape.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rewrite: 2,000 lines to 200
&lt;/h2&gt;

&lt;p&gt;I was implementing drag &amp;amp; drop for position planning and hit a wall — libraries couldn’t work within d3-org-chart’s shadow DOM. I had three options: ignore it, write a custom implementation, or rewrite the chart as a native Vue component.&lt;/p&gt;

&lt;p&gt;I went with option 3. Had a hunch it would be quick for this case — no expand/collapse needed, I knew how to extract d3’s tree layout calculations, and we only needed root + one level of children.&lt;/p&gt;

&lt;p&gt;It took 4-5 hours. The result was ~200 lines. A template iterating over data, setting transform attributes on SVG nodes and lines. No custom elements, no shadow DOM, no separate Vue instances.&lt;/p&gt;

&lt;p&gt;The key: &lt;strong&gt;let d3 calculate the layout, let Vue render the DOM.&lt;/strong&gt; The old approach used d3 for both, then hacked Vue on top.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// d3 calculates positions&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hierarchy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// Vue renders&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;g v-for="node in root.descendants()" :transform="`translate(${node.x},${node.y})`"&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;OrgChartCard :person="node.data" /&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;/g&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;d3-org-chart&lt;/th&gt;
&lt;th&gt;Native Vue&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;1 min 45 sec&lt;/td&gt;
&lt;td&gt;&amp;lt; 5 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;5.5 GB&lt;/td&gt;
&lt;td&gt;1.1 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;2,000+ lines&lt;/td&gt;
&lt;td&gt;~200 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Lazy loading custom fields
&lt;/h2&gt;

&lt;p&gt;Next bottleneck: custom fields. User-defined attributes stored in PostgreSQL. For a 30k org with 20 types, that’s 600k rows. We loaded all of them on startup.&lt;/p&gt;

&lt;p&gt;What I found:&lt;br&gt;&lt;br&gt;
– most features didn’t need ALL values&lt;br&gt;&lt;br&gt;
– filters only needed unique values per type — one SQL query, sub-second even for 600k rows&lt;br&gt;&lt;br&gt;
– org chart cards only needed values for visible nodes&lt;/p&gt;

&lt;p&gt;So I built two backend endpoints (unique values + filtered employees) and used TanStack Query to fetch values per person as they enter the viewport. Cache policy handles the rest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Directory memory (30k org)&lt;/td&gt;
&lt;td&gt;400-500 MB&lt;/td&gt;
&lt;td&gt;200-300 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full app path&lt;/td&gt;
&lt;td&gt;1,400 MB&lt;/td&gt;
&lt;td&gt;900 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Don’t load all people
&lt;/h2&gt;

&lt;p&gt;The big one. We were fetching the entire &lt;code&gt;people&lt;/code&gt; collection on app load. For 240k employees that meant decompressing and parsing everything before anything could render.&lt;/p&gt;

&lt;p&gt;I moved JSON decompression to a Web Worker first (kept the UI responsive but didn’t reduce memory). Then rethought the whole approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The frontend doesn’t need all people data. It needs the tree structure and data for visible nodes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used d3 hierarchy functions on the backend to build the full tree from &lt;code&gt;{id, parentId}&lt;/code&gt; pairs. For 240k employees this takes ~1 second server-side. The backend returns:&lt;br&gt;&lt;br&gt;
– lightweight hierarchy pairs for the entire org&lt;br&gt;&lt;br&gt;
– full records only for requested nodes (batched by what’s on screen)&lt;/p&gt;

&lt;p&gt;Frontend renders the tree immediately from hierarchy, shows placeholder cards. As nodes enter the viewport, it batches requests for their data. TanStack Query handles caching and background refresh.&lt;/p&gt;

&lt;p&gt;This meant rewriting basically every feature that previously iterated over &lt;code&gt;people&lt;/code&gt;. The changelog for this release was the longest I’d ever written.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory on load (240k)&lt;/td&gt;
&lt;td&gt;1.8 GB&lt;/td&gt;
&lt;td&gt;0.8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter apply&lt;/td&gt;
&lt;td&gt;9 sec&lt;/td&gt;
&lt;td&gt;1.5 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter reset&lt;/td&gt;
&lt;td&gt;15 sec&lt;/td&gt;
&lt;td&gt;2.5 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30k org total memory&lt;/td&gt;
&lt;td&gt;1+ GB&lt;/td&gt;
&lt;td&gt;&amp;lt; 400 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Scenarios migration
&lt;/h2&gt;

&lt;p&gt;The main org chart was fast. But scenario planning still loaded all people upfront.&lt;/p&gt;

&lt;p&gt;Migrating scenarios was the hardest part. Every operation had assumptions about in-memory data. Move employee — previously instant (mutate local state), now needs API call + cache invalidation. Remove employee — 4-5 different code paths depending on whether they’re a backfill, have subordinates, etc.&lt;/p&gt;

&lt;p&gt;Trickiest bug: cache race condition where moving an employee invalidates people cache, hierarchy endpoint fires immediately, cache is half-rebuilt, returns single employee.&lt;/p&gt;

&lt;p&gt;I used TanStack Query cache manipulation for optimistic updates on simple operations, loading states for complex ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each phase was the minimum change for the next order of magnitude.&lt;/strong&gt; Dictionary lookups for 10k. New rendering for 30k. Backend hierarchy for 240k. If I’d built the final architecture in 2022 I would have over-engineered for problems that didn’t exist yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bottleneck was never what I expected.&lt;/strong&gt; I assumed DOM rendering. It was actually: unnecessary Vue instance creation, array iteration patterns (&lt;code&gt;.includes()&lt;/code&gt; inside loops), synchronous JSON decompression, loading data we didn’t need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend compute is cheap, memory is not.&lt;/strong&gt; Hierarchy building + filtering in Node.js: 1-2 seconds for 240k records. Saved gigabytes of browser memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data-driven scope cutting.&lt;/strong&gt; I skipped reimplementing directory search — usage data showed 3 uses in a month. Same for column sorting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance improvements compound.&lt;/strong&gt; Vue rewrite enabled viewport rendering. Viewport rendering enabled on-demand fetching. On-demand fetching enabled TanStack Query caching. By the end, 30k orgs felt like cheating.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Org chart library&lt;/td&gt;
&lt;td&gt;2,000+ lines&lt;/td&gt;
&lt;td&gt;~200 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10k org render&lt;/td&gt;
&lt;td&gt;1:45 / 5.5 GB&lt;/td&gt;
&lt;td&gt;5 sec / 1.1 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;240k org load&lt;/td&gt;
&lt;td&gt;browser crash&lt;/td&gt;
&lt;td&gt;0.8 GB, usable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter operations (240k)&lt;/td&gt;
&lt;td&gt;9 sec&lt;/td&gt;
&lt;td&gt;1.5 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directory memory (30k)&lt;/td&gt;
&lt;td&gt;500 MB&lt;/td&gt;
&lt;td&gt;200 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At the end of 2023, opening a 30k org in dev would crash the browser in 5 seconds. Six months later I could keep it open all day.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://oskarszulc.com/making-a-240k-record-org-chart-load-in-a-browser/" rel="noopener noreferrer"&gt;oskarszulc.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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