<?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: Sourav Bhowmik</title>
    <description>The latest articles on DEV Community by Sourav Bhowmik (@sourav_bhowmik_73d35592ab).</description>
    <link>https://dev.to/sourav_bhowmik_73d35592ab</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%2F3502057%2Ffb268607-5873-4091-b997-bb39e42c5d18.png</url>
      <title>DEV Community: Sourav Bhowmik</title>
      <link>https://dev.to/sourav_bhowmik_73d35592ab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sourav_bhowmik_73d35592ab"/>
    <language>en</language>
    <item>
      <title>From Full-Table Scans to Millisecond Search in Go: Optimizing Prefix Search for 13.5 Million Places</title>
      <dc:creator>Sourav Bhowmik</dc:creator>
      <pubDate>Wed, 28 Oct 2026 23:48:26 +0000</pubDate>
      <link>https://dev.to/sourav_bhowmik_73d35592ab/from-full-table-scans-to-millisecond-search-in-go-optimizing-prefix-search-for-135-million-places-3i2h</link>
      <guid>https://dev.to/sourav_bhowmik_73d35592ab/from-full-table-scans-to-millisecond-search-in-go-optimizing-prefix-search-for-135-million-places-3i2h</guid>
      <description>&lt;p&gt;I built a minimal backend that searches a huge dataset and returns the 10 most popular matches. I wanted to find the bottlenecks in a simple search implementation and measure how each optimization changed performance. I started with the simplest version and improved it step by step, using the same data and query prefixes throughout. At the end of the final version, the query time for a single keyword &lt;code&gt;a&lt;/code&gt; drops from 2.6 seconds to 2.9 milliseconds.&lt;/p&gt;

&lt;p&gt;It is written in Go, with the standard library only. There is one search endpoint and one HTML page, both served by the same Go program. The code, the results and the git tags for each level are in the repo: &lt;a href="https://github.com/bhowmik94/autocomplete-10M" rel="noopener noreferrer"&gt;https://github.com/bhowmik94/autocomplete-10M&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A query for the one-letter prefix &lt;code&gt;a&lt;/code&gt; over 13.5 million places took 2.6 seconds in my first version. In the final version it takes 2.9 milliseconds. A longer prefix like &lt;code&gt;dhaka&lt;/code&gt; went from 2.4 seconds to under 2 microseconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; These benchmarks measure the search function, not a deployed API.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffpfxihq7phe2r6bzi1wr.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffpfxihq7phe2r6bzi1wr.gif" alt="Typing dhaka into the search box, with results updating after each letter" width="755" height="767"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Dataset and method
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Data:&lt;/strong&gt; I used the &lt;code&gt;allCountries&lt;/code&gt; file from GeoNames, which has 13,472,243 rows. Each row is a place: a city, a village, a district and so on. The file has 19 columns, and I use only four: ID, name, country code and population. &lt;/p&gt;

&lt;p&gt;I developed everything on &lt;code&gt;cities15000&lt;/code&gt; first, a smaller file with about 34,000 rows, and moved to &lt;code&gt;allCountries&lt;/code&gt; once the naive version worked. I chose GeoNames over other free datasets, like Wikipedia article titles, because it has a population column, which gives a clear way to rank the results. The data is licensed under CC BY 4.0 and comes from GeoNames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking:&lt;/strong&gt; Matches are sorted by population, highest first. If two places have the same population, the higher ID wins. Matching is case-insensitive and ignores leading spaces. I kept the ranking basic on purpose, because it was not the primary goal of this study.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine:&lt;/strong&gt; AMD Ryzen 5 5500U, Windows, 12 threads, Go 1.26.4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmark prefixes:&lt;/strong&gt; I measured four prefixes that behave very differently. The block size is the number of places whose name starts with the prefix. I estimated it from the memory the Level 1 version allocates, so treat it as approximate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Block size&lt;/th&gt;
&lt;th&gt;Why it is in the set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 590,000&lt;/td&gt;
&lt;td&gt;Huge block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 12,000&lt;/td&gt;
&lt;td&gt;Medium block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dhaka&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 200&lt;/td&gt;
&lt;td&gt;Small block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xyzq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No match&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping it fair: Every level runs on the same data, the same prefixes and k = 10. I ran the same benchmark cases against every implementation. A separate test compares each version with the naive one for every prefix in a small dataset. The naive version is slow but simple, so I used it as the reference for correct answers. Before building each level, I wrote down what I expected to measure and compared it afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 0: the naive version
&lt;/h2&gt;

&lt;p&gt;For every query, the naive version goes through all rows, lowercases each name, checks whether it starts with the prefix, collects the matches, sorts them by population and returns the top 10.&lt;/p&gt;

&lt;p&gt;On the 34,000-row file, a query for &lt;code&gt;dha&lt;/code&gt; took about 3.5 ms. On the full file it took about 1.4 seconds. That is roughly 105 ns per row in both cases, so the cost grows in line with the number of rows. A prefix with no match (&lt;code&gt;xyzq&lt;/code&gt;) took as long as &lt;code&gt;dha&lt;/code&gt;, which shows that the cost is the scan itself, not the matching. The one-letter prefix &lt;code&gt;a&lt;/code&gt; was the slowest, because about 590,000 places match it and have to be collected and sorted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Time per query&lt;/th&gt;
&lt;th&gt;Memory per query&lt;/th&gt;
&lt;th&gt;Allocations per query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.76 s&lt;/td&gt;
&lt;td&gt;414 MB&lt;/td&gt;
&lt;td&gt;13.47 million&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.41 s&lt;/td&gt;
&lt;td&gt;244 MB&lt;/td&gt;
&lt;td&gt;13.47 million&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dhaka&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.47 s&lt;/td&gt;
&lt;td&gt;242 MB&lt;/td&gt;
&lt;td&gt;13.47 million&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xyzq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.45 s&lt;/td&gt;
&lt;td&gt;242 MB&lt;/td&gt;
&lt;td&gt;13.47 million&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The allocation count is the useful clue. It is one allocation per row, which is the lowercase copy of every name that is created on every query. That is why even a prefix with no matches allocates 242 MB. In the naive version, memory depends on the size of the dataset, not on the size of the matching block.&lt;/p&gt;

&lt;p&gt;Loading the full file took about 9.8 seconds and the heap was 993.5 MB afterwards. Getting there needed one memory fix. Go’s strings.Split returns pieces that point into the original text, so keeping a single city name keeps the whole file text alive. On the small file the heap after loading was 10.2 MB. Copying each name with strings.Clone brought it down to 2.5 MB with the same load time. On the full file the unused text would have been over a gigabyte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 1: sort once, binary search
&lt;/h2&gt;

&lt;p&gt;The naive version repeats the same work on every query. Level 1 does that work once, at startup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each place gets a &lt;code&gt;LowerName&lt;/code&gt; field, computed once when the data is loaded.&lt;/li&gt;
&lt;li&gt;The whole list is sorted once by that field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After sorting, all places whose names start with the same prefix sit next to each other in one unbroken stretch of the array. I call this stretch the &lt;strong&gt;block&lt;/strong&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%2Ffmlnarlv2n10k6bh5m90.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmlnarlv2n10k6bh5m90.png" alt="What is a block?" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two binary searches find the block: one for its start, one for its end. &lt;br&gt;
To find the end, I search for the prefix with its last character incremented—for example, &lt;code&gt;dha&lt;/code&gt; becomes &lt;code&gt;dhb&lt;/code&gt;. Every &lt;code&gt;dha&lt;/code&gt; match sorts before that boundary. Then I copy the block, sort the copy by population and return the first 10. The copy is important since the block is a window into the shared array, and sorting it in place would reorder the index for every later query.&lt;/p&gt;

&lt;p&gt;The speedup has a price. The extra lowercase names raised the heap from 993.5 MB to 1,403.6 MB. The one-time sort takes about 10 seconds on top of about 10 seconds of parsing, so startup is now about 20 seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Naive&lt;/th&gt;
&lt;th&gt;Level 1&lt;/th&gt;
&lt;th&gt;Level 1 memory per query&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.64 s&lt;/td&gt;
&lt;td&gt;144 ms&lt;/td&gt;
&lt;td&gt;37.7 MB&lt;/td&gt;
&lt;td&gt;about 18x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.34 s&lt;/td&gt;
&lt;td&gt;2.1 ms&lt;/td&gt;
&lt;td&gt;771 KB&lt;/td&gt;
&lt;td&gt;about 1,100x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dhaka&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.39 s&lt;/td&gt;
&lt;td&gt;19 µs&lt;/td&gt;
&lt;td&gt;14 KB&lt;/td&gt;
&lt;td&gt;about 124,000x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xyzq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.33 s&lt;/td&gt;
&lt;td&gt;572 ns&lt;/td&gt;
&lt;td&gt;32 B&lt;/td&gt;
&lt;td&gt;about 4 million x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on the naive column: after the data was sorted, the naive version ran slower than in my first measurement (2.3 to 2.6 seconds instead of 1.4 to 1.8), although its code had not changed. I suspect cache misses: the sorted records still point to names laid out in file order. I haven’t tested that explanation. Every speedup in this article compares numbers from the same run.&lt;/p&gt;

&lt;p&gt;Two things stand out. A no-match query takes 572 ns: it only needs two binary searches. For matching queries, time depends on the block size. &lt;code&gt;a&lt;/code&gt; matches about 590,000 places and takes 144 ms; &lt;code&gt;dha&lt;/code&gt; matches about 12,000 and takes 2.1 ms. The number of allocations is constant (6), but the bytes still grow with the block, because the block is copied and sorted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level 2: do not sort what you will not use
&lt;/h2&gt;

&lt;p&gt;Level 1 copies and sorts 590,000 places to return 10 of them. Level 2 walks the block once and keeps a list of the best 10 places seen so far, sorted best first. Most places are worse than the current tenth-best result, so I skip them. If a place is better, I move it into the right position. Otherwise it replaces the worst one and moves up to its position. There is no copy and no sort. For k = 10, a small sorted slice is enough. I did not try a heap, which should only help for a large k.&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%2Flex2cepulhhccbgxm534.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flex2cepulhhccbgxm534.png" alt="Top K single pass" width="800" height="564"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Level 1&lt;/th&gt;
&lt;th&gt;Level 2&lt;/th&gt;
&lt;th&gt;Level 2 memory per query&lt;/th&gt;
&lt;th&gt;Speedup over Level 1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;144 ms&lt;/td&gt;
&lt;td&gt;2.9 ms&lt;/td&gt;
&lt;td&gt;708 B&lt;/td&gt;
&lt;td&gt;about 49x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.13 ms&lt;/td&gt;
&lt;td&gt;47 µs&lt;/td&gt;
&lt;td&gt;712 B&lt;/td&gt;
&lt;td&gt;about 45x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dhaka&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;19.3 µs&lt;/td&gt;
&lt;td&gt;1.9 µs&lt;/td&gt;
&lt;td&gt;712 B&lt;/td&gt;
&lt;td&gt;about 10x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xyzq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;572 ns&lt;/td&gt;
&lt;td&gt;454 ns&lt;/td&gt;
&lt;td&gt;8 B&lt;/td&gt;
&lt;td&gt;about 1.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory per query is now about 700 bytes (the ten places), no matter how big the block is. The no-match prefix barely changed, since it was already just two binary searches.&lt;/p&gt;

&lt;p&gt;The remaining 2.9 ms for &lt;code&gt;a&lt;/code&gt; looks like a memory limit. The block contains about 590,000 records of 64 bytes each, or roughly 38 MB. Scanning it in 2.9 ms works out to about 13 GB/s—close to the bandwidth I’d expect from a laptop. The loop only needs the population and the ID of each place, but it pulls in all 64 bytes. So the limit is now the memory layout, not the algorithm.&lt;/p&gt;

&lt;p&gt;Across both steps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Naive&lt;/th&gt;
&lt;th&gt;Level 1&lt;/th&gt;
&lt;th&gt;Level 2&lt;/th&gt;
&lt;th&gt;Total speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.64 s&lt;/td&gt;
&lt;td&gt;144 ms&lt;/td&gt;
&lt;td&gt;2.9 ms&lt;/td&gt;
&lt;td&gt;about 900x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.34 s&lt;/td&gt;
&lt;td&gt;2.1 ms&lt;/td&gt;
&lt;td&gt;47 µs&lt;/td&gt;
&lt;td&gt;about 50,000x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dhaka&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.39 s&lt;/td&gt;
&lt;td&gt;19 µs&lt;/td&gt;
&lt;td&gt;1.9 µs&lt;/td&gt;
&lt;td&gt;about 1.3 million x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xyzq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.33 s&lt;/td&gt;
&lt;td&gt;572 ns&lt;/td&gt;
&lt;td&gt;454 ns&lt;/td&gt;
&lt;td&gt;about 5 million x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq50bfl7vqw64mddtjuxj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq50bfl7vqw64mddtjuxj.png" alt="Bar chart: query time falls from 2.64 s to 2.9 ms" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving it over HTTP
&lt;/h2&gt;

&lt;p&gt;There is one search endpoint, for example, &lt;code&gt;GET /suggest?q=dha&amp;amp;limit=5&lt;/code&gt;. The default limit is 10, and values outside 1–50 are clamped. Non-integer values return a 400 response. The search function is a field of the server struct, so any of the three levels can run behind the same endpoint.&lt;/p&gt;

&lt;p&gt;Go handles each request in its own goroutine, so many requests read the index at the same time. That is safe because nothing modifies the index after startup: each query only reads it and builds its own 10-place result. If a query had sorted the shared array in place, requests would have corrupted each other. A test with 50 concurrent requests, using Go’s &lt;code&gt;httptest&lt;/code&gt; package, runs against the handler.&lt;/p&gt;

&lt;p&gt;The HTML page is a single static file served by the same Go program at &lt;code&gt;/&lt;/code&gt;. Serving it from the same place as the API avoids CORS issues and keeps the project small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No end-to-end load test.&lt;/strong&gt; The timings above call the Go functions directly. They leave out the network, HTTP parsing and JSON encoding, so a real request takes longer. Everything ran on one laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimates.&lt;/strong&gt; The block sizes are estimated from allocated memory, and the slowdown of the naive version on sorted data is an untested guess. The naive version was measured with five iterations per prefix, because each run takes seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Basic search quality.&lt;/strong&gt; Only the name is matched, with no accent folding, so &lt;code&gt;munchen&lt;/code&gt; does not find München. Alternate names are not indexed, places with the same name are listed separately, and ranking uses population only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Startup and memory.&lt;/strong&gt; Startup takes about 20 seconds and the index needs about 1.4 GB of RAM per instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What could come next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store populations and IDs in separate compact arrays, so the scan for &lt;code&gt;a&lt;/code&gt; reads a few bytes per place instead of 64.&lt;/li&gt;
&lt;li&gt;Use a range-maximum structure (a sparse table or a segment tree) with a heap, so the cost depends on k and not on the block size.&lt;/li&gt;
&lt;li&gt;Precompute the top 10 for the shortest prefixes.&lt;/li&gt;
&lt;li&gt;Compare against Postgres with an index and a cache, and deploy it to the cloud.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write predictions down before measuring.&lt;/strong&gt; It shows you what you did not understand yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allocation counts show where time goes.&lt;/strong&gt; 13.47 million allocations per query pointed straight at the lowercasing of every name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost can depend on the part of the data a query touches, not on the dataset size.&lt;/strong&gt; After Level 1, a query over 13.5 million places that matches nothing takes 572 ns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory layout can matter as much as the algorithm.&lt;/strong&gt; Level 2 runs at roughly the speed of memory, and the naive version may have got slower just because the data was reordered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the slow, simple version.&lt;/strong&gt; The naive version was my reference for correct answers in every test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code, results and git tags: &lt;a href="https://github.com/bhowmik94/autocomplete-10M" rel="noopener noreferrer"&gt;https://github.com/bhowmik94/autocomplete-10M&lt;/a&gt;. Data: GeoNames, CC BY 4.0.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>go</category>
      <category>performance</category>
    </item>
    <item>
      <title>How I Cut a React App's Initial Bundle by 89%</title>
      <dc:creator>Sourav Bhowmik</dc:creator>
      <pubDate>Wed, 30 Sep 2026 17:40:53 +0000</pubDate>
      <link>https://dev.to/sourav_bhowmik_73d35592ab/how-i-cut-a-react-apps-initial-bundle-by-89-58k2</link>
      <guid>https://dev.to/sourav_bhowmik_73d35592ab/how-i-cut-a-react-apps-initial-bundle-by-89-58k2</guid>
      <description>&lt;p&gt;Building and shipping applications is an integral part of a software developer's life. In this article I show you how I ran an experiment on the initial bundle size of a frontend web app build, specifically, which factors are responsible for bundle bloat, and how much of it can actually be reduced. My baseline build shipped a 634 KB (gzip) initial bundle. After running four optimization experiments, it dropped to 71 KB, a staggering 89% reduction!&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;I built a minimal activity-dashboard app in React, with help from Claude AI. The baseline version deliberately includes a few heavy dependencies: &lt;code&gt;react-icons&lt;/code&gt;, &lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;recharts&lt;/code&gt;, and &lt;code&gt;moment&lt;/code&gt;; each picked for a reason that seemed sensible at the time, not out of carelessness.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;moment&lt;/strong&gt;, because it's one of the most popular date libraries in JS and handling relative timestamps correctly (pluralization, day boundaries) felt like something worth not hand-rolling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lodash&lt;/strong&gt;, because I needed &lt;code&gt;debounce&lt;/code&gt; for the search input and &lt;code&gt;groupBy&lt;/code&gt; to cluster activities by date; pulling in a well-known utility library felt like the obvious choice. &lt;em&gt;(There is a surprise bug for the lodash imports! Continue reading to find out&lt;/em&gt; 😉&lt;em&gt;)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;recharts&lt;/strong&gt;, because the Dashboard page just needed one simple line chart, and it didn't seem worth reaching for something heavier like Chart.js.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app has three pages: &lt;strong&gt;Feed&lt;/strong&gt; (the landing page, showing dummy activity items), &lt;strong&gt;Dashboard&lt;/strong&gt; (a line chart of weekly activity via recharts), and &lt;strong&gt;Settings&lt;/strong&gt; (empty, included only to demonstrate route-based code splitting).&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%2Fcaybys4dtxw8f3g4olkk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaybys4dtxw8f3g4olkk.png" alt="Feed page" width="800" height="536"&gt;&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%2Fidhoc9n1dddkhwdmkt7g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidhoc9n1dddkhwdmkt7g.png" alt="Dashboard page" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For measuring bundle composition, I used &lt;code&gt;rollup-plugin-visualizer&lt;/code&gt;:&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;// vite.config.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;visualizer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rollup-plugin-visualizer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;visualizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist/stats.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gzipSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a &lt;code&gt;stats.html&lt;/code&gt; treemap after every build, showing exactly which libraries and functions are taking up space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiments
&lt;/h2&gt;

&lt;p&gt;I ran four experiments from the baseline, each isolated on its own branch, to measure the individual impact of a single fix on initial bundle size. A final branch combines all four.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fixing the icon import
&lt;/h3&gt;

&lt;p&gt;This was the single biggest win. The baseline imported the entire icon set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Icons&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-icons/fa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looked necessary because the icon to render is resolved at runtime from a &lt;code&gt;category&lt;/code&gt; field in the data — so it seemed like there was no way to know in advance which icons I'd need. But the category set is actually small and known ahead of time, so named imports work fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FaComment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FaUpload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FaAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FaHeart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FaUserPlus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-icons/fa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CATEGORY_TO_ICON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FaComment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FaUpload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mention&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FaAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;like&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FaHeart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FaUserPlus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single change cut the initial bundle by &lt;strong&gt;67%&lt;/strong&gt; — from 634 KB to 209 KB (gzip).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Lazy-loading the Dashboard route
&lt;/h3&gt;

&lt;p&gt;The Dashboard page's line chart (&lt;code&gt;recharts&lt;/code&gt;) was being loaded on every page visit, even for users who never opened the Dashboard. Wrapping the route in &lt;code&gt;React.lazy&lt;/code&gt; + &lt;code&gt;Suspense&lt;/code&gt; defers that cost until someone actually navigates there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Dashboard&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pages/Dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Dashboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pages/Dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Routes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/dashboard"&lt;/span&gt; &lt;span class="na"&gt;element&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="nc"&gt;Dashboard&lt;/span&gt; &lt;span class="p"&gt;/&amp;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="cm"&gt;/* ...other routes */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Routes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&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;This reduced the initial bundle by &lt;strong&gt;17%&lt;/strong&gt;, from 634 KB to 529 KB (gzip).&lt;/p&gt;

&lt;p&gt;Worth calling out: this fix doesn't make &lt;code&gt;recharts&lt;/code&gt; disappear; it rather moves it into a separate chunk that loads on demand. So while the &lt;em&gt;initial&lt;/em&gt; download shrinks by 17%, the &lt;em&gt;total&lt;/em&gt; JS the app ships (initial + Dashboard chunk combined) barely changes. That distinction, initial load vs. total weight, turned out to matter a lot for how I read all four results, and especially the combined one at the end.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fixing the lodash import
&lt;/h3&gt;

&lt;p&gt;The baseline imported lodash like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;groupBy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lodash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;em&gt;looks&lt;/em&gt; like a named import, but it isn't one; at least not in a way that helps. Lodash's main entry point is a CommonJS module, so the bundler can't statically determine which functions are actually used and ends up including the whole library anyway. It's effectively the same as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;_&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lodash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to import each function from its own subpath:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;debounce&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lodash/debounce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;groupBy&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lodash/groupby&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This properly tree-shakes and cuts the bundle by &lt;strong&gt;5%&lt;/strong&gt;; a small number, but the mistake itself is the more useful finding: it's easy to write an import that looks tree-shaken and isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;N.B.&lt;/strong&gt; There is a subtle and easy-to-miss bug in the second lodash import &lt;code&gt;lodash/groupby&lt;/code&gt;. Named imports are case-sensitive, so the correct import path should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;groupBy&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lodash/groupBy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then how did this work on my machine? Because Windows and MacOS filesystems are not case-sensitive. So the import path worked on my Windows environment. But this would raise an error in a Linux environment.&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;lodash-es&lt;/code&gt; with named imports (&lt;code&gt;import { debounce, groupBy } from 'lodash-es'&lt;/code&gt;) will completely remove this bug and make things even easier. &lt;strong&gt;And since &lt;code&gt;lodash-es&lt;/code&gt; is a proper ES Module, tree shaking is embedded by default.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Replacing moment with date-fns
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;moment&lt;/code&gt; is a large, feature-complete library, and reaching for it as a first instinct for date handling is common since it's intuitive and well-documented. But most of that surface area goes unused in any single app, and unlike modern alternatives, moment isn't built to tree-shake. Swapping it for &lt;code&gt;date-fns&lt;/code&gt;, which ships as individual function modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="nf"&gt;moment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fromNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="nf"&gt;formatDistanceToNow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;addSuffix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produced the smallest change of the four — a &lt;strong&gt;2%&lt;/strong&gt; reduction. But in an app with heavier date usage than this one, that gap would likely be much larger; this app only touches three moment methods, so there wasn't much bulk to remove in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combined result
&lt;/h2&gt;

&lt;p&gt;Applying all four fixes together dropped the initial bundle from 634 KB to &lt;strong&gt;71 KB&lt;/strong&gt; (gzip) — an &lt;strong&gt;89%&lt;/strong&gt; reduction.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Initial download&lt;/strong&gt; (what a first-time visitor gets)&lt;/td&gt;
&lt;td&gt;634 KB&lt;/td&gt;
&lt;td&gt;71 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-89%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Total JS shipped&lt;/strong&gt; (initial + Dashboard chunk)&lt;/td&gt;
&lt;td&gt;634 KB&lt;/td&gt;
&lt;td&gt;168 KB&lt;/td&gt;
&lt;td&gt;-74%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap between those two rows is the point of lazy-loading: total app weight didn't shrink as much as the initial load did, because the chart code still exists; it just isn't downloaded until someone actually needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The goal here was to find out which kinds of bundle bloat actually matter, not just to list generic advice. Of the four fixes, the wildcard icon import alone accounted for a 67% reduction — more than the other three combined. If you're short on time, auditing wildcard/dynamic imports is probably the highest-leverage place to start.&lt;/p&gt;

&lt;p&gt;It's also worth knowing that named imports aren't automatically tree-shaken. Instead, it depends on how the library itself is structured, and it's easy to get this wrong without realizing it, as the lodash example shows.&lt;/p&gt;

&lt;p&gt;The full project, including each experiment on its own branch and the combined final version, is on GitHub: &lt;em&gt;[&lt;a href="https://github.com/bhowmik94/bundle-experiment" rel="noopener noreferrer"&gt;https://github.com/bhowmik94/bundle-experiment&lt;/a&gt;]&lt;/em&gt;. The &lt;code&gt;experiments/&lt;/code&gt; folder has the bundle visualizer output for each build, showing the exact size breakdown per library and function, if you want to dig deeper. You are welcome to reproduce the same results and see for yourself. Just fork the project and have fun! Please feel free to leave any questions or comments below for further discussions and improvements. And if you are feeling generous, consider leaving a heart in this article.&lt;/p&gt;

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