I swear software development has a weird retirement plan.
You start as a developer.
Then after years of debugging, production fires, broken deployments, and wondering why a button moved 3 pixels to the left after a CSS change, your brain eventually says:
"Maybe I should just plant succulents."
Then you look at cactus collections.
Then hydroponics.
Then suddenly you're watching YouTube videos about soil mixtures at 2AM like some exhausted little goblin.
The funny thing is, developers don't really quit being developers.
We just become gardeners.
Except instead of watering plants, we water codebases.
Instead of removing dead leaves, we remove dead dependencies.
Instead of pruning branches, we prune JavaScript bundles.
The difference?
Plants don't throw minified React errors at you.
This Was Not Supposed To Be A Performance Story
This started as something completely different.
I was not planning to touch bundle optimization.
I was doing UX improvements.
The goal was simple:
Make the dashboard experience better on tablets and mobile.
Because desktop lied to me.
My development machine is powerful enough to hide bad architecture.
A Ryzen 7 with 32GB RAM looks at questionable frontend decisions and says:
"Cute."
Then a tablet opens the same application and says:
"Why the fuck am I downloading an entire CMS just to view a dashboard?"
Before this, I was already working through a bunch of improvements:
Focus mode improvements
Dashboard UX updates
Blog refactoring
Mobile and tablet improvements
Then my brain naturally moved to bigger things.
A page builder.
Advanced security improvements.
More enterprise-style features.
I actually planned them properly.
Not the classic developer strategy:
"I have an idea. Let's rewrite everything at 3AM."
No.
Actual planning.
Sprint 1
Page Builder foundation.
Sprint 2
Advanced security refactor.
The architecture was mapped.
The ideas were ready.
I thought:
"Okay. I'm ready."
Then I deployed the UX updates.
Opened the dashboard on a tablet.
And then:
Why the fuck is this slow?
The Developer Investigation Ritual
The first instinct:
Blame the internet.
Obviously.
The internet is always guilty.
Then:
Maybe Firebase?
Maybe API latency?
Maybe React?
Maybe the tablet is just old?
Then I stopped.
Because sometimes the bug is not a bug.
Sometimes the system is telling you:
"Hey idiot. Look here."
So I started digging.
First Stop: npm run lint
I thought:
"Maybe today is just cleanup day."
Run lint.
Except...
There was no proper lint script.
Past Maiko strikes again.
Apparently, at some point, ESLint was installed.
Packages existed.
Configuration existed.
But the important part?
Actually wiring it into the project?
Forgotten.
The software equivalent of buying gym equipment and using it as a clothes hanger.
After fixing ESLint:
Clean.
No warnings.
No errors.
React hook warnings?
Fixed.
TypeScript issues?
Fixed.
The codebase was finally behaving.
Then I ran:
npm run buildAnd everything changed.
The Horror: The Bundle
The build output:
vite v8.1.3 building client environment for production...
✓ 12756 modules transformed.
dist/assets/index-C5nR1aOm.js
2,308.19 kB
gzip: 662.66 kBI stared.
Then I asked:
"Why the fuck is everything in one file?"
The application was basically doing this:
Open dashboard
↓
Download entire application
↓
Parse everything
↓
Initialize everything
↓
Finally show the dashboard
A user opening /home was secretly paying the cost for:
Analytics
Blog editor
TipTap
Charts
Comment moderation
Settings
Document processing
Every future feature
Everything.
The browser was carrying around a backpack full of rocks.
The Tablet Was Innocent
The tablet was not slow.
The architecture was guilty.
The old flow:
User opens dashboard
↓
Download 2.3MB JavaScript
↓
Parse JavaScript
↓
Compile JavaScript
↓
Initialize libraries
↓
Initialize React
↓
Initialize everything
↓
Show dashboardThe tablet was basically being told:
"Before you can see your dashboard, please eat this entire application."
The Previous Company Horror Finally Made Sense
This also explained something I had seen before.
A release goes out.
Someone opens the application.
White screen.
Someone says:
"Clear your cache."
They clear cache.
Suddenly everything works.
The classic frontend exorcism ritual.
At the time it looked random.
It wasn't.
When applications use large generated chunks, deployments can create situations where:
The HTML points to new chunk filenames
The browser still has old cached JavaScript
The application tries loading something that no longer exists
The frontend is not necessarily broken.
The browser and deployment are just having a disagreement.
And like most disagreements in software:
Nobody logged it properly.
Sprint 1 And Sprint 2 Are Cancelled
The page builder can wait.
The security refactor can wait.
Because none of those matter if the first experience is:
"Please stare at a loading screen while your device suffers."
New priority:
Sprint 0 — Stop Making The Browser Suffer
Phase 1 — Route-Level Code Splitting
The first problem:
Everything loaded immediately.
Solution:
React lazy loading.
Before:
import AnalyticsOverview from "./analytics/AnalyticsOverview";
import GoogleAnalytics from "./analytics/GoogleAnalytics";
import BlogPostForm from "./blog/CreateBlogPost";Everything entered the initial bundle.
After:
const AnalyticsOverview = lazy(
() => import("./analytics/AnalyticsOverview")
);
const GoogleAnalytics = lazy(
() => import("./analytics/GoogleAnalytics")
);
const BlogPostForm = lazy(
() => import("./blog/CreateBlogPost")
);Now:
Dashboard loads dashboard.
Analytics loads analytics.
Blog editor loads when someone actually writes.
Crazy concept.
Result:
Before:
index.js
2.3MBAfter:
index.js
~463KBThe application shell became dramatically smaller.
Phase 2 — The Blog Editor Monster
Then I found another beast.
The blog editor.
Expected.
Rich text editors are not small.
TipTap brings:
ProseMirror
Extensions
Editor utilities
Parsing logic
Before:
CreateBlogPost.js
1,005KBA blog editor was basically carrying a backpack full of bricks.
So:
Lazy load it.
const BlogEditor = lazy(
() => import("./BlogEditor")
);Result:
CreateBlogPost.js
49KBThe form loads.
The editor loads only when needed.
Phase 3 — Charts Are Not Free
Analytics had another hidden monster.
Recharts.
Before:
AnalyticsCharts.js
387KBThe dashboard did not need charts.
Analytics did.
So charts moved behind Suspense boundaries.
Result:
Dashboard:
No chart dependency.
Analytics:
Load charts.Simple.
Phase 4 — The MUI Icon Disaster
Then came something embarrassing.
MUI icons.
The innocent import:
import { ThumbUp } from "@mui/icons-material";Looks harmless.
It is not.
Barrel imports can make bundlers include more than necessary.
Changed to:
import ThumbUp from "@mui/icons-material/ThumbUp";Result:
Before:
ThumbUp chunk
293KBAfter:
Gone.A whole chunk disappeared because of imports.
Phase 5 — Document Processing Final Boss
The blog editor had document features:
Import DOCX
Export Markdown
Export HTML
Useful.
But nobody needs Word parsing while typing a title.
The problem:
import mammoth from "mammoth";
import marked from "marked";
import TurndownService from "turndown";These loaded immediately.
Moved them into the actual functions using dynamic imports.
Before:
Open editor
↓
Download document convertersAfter:
Click Import DOCX
↓
Download mammothResult:
Before:
BlogEditor
952KBAfter:
BlogEditor
401KBThe heavy document tools became isolated:
mammoth
↓
jszip
↓
marked
↓
turndown
↓
file-saverThey only exist when somebody asks for them.
Final Production Audit
After all optimizations:
Initial shell
index.js
~462KBApproximately:
138KB gzipThis contains:
React
Router
Firebase auth
Dashboard shell
Critical startup code
Feature chunks
Blog Editor
BlogEditor.js
~402KBOptimal.
TipTap and ProseMirror are unavoidable costs for a rich editor.
Splitting every extension individually would create network waterfalls and worse performance.
Analytics Charts
AnalyticsCharts.js
~388KBPerfectly isolated.
Only loads when analytics actually needs charts.
Document Processing
lib.js
~401KB
jszip.js
~96KBNever downloaded unless the user imports or exports documents.
React Query Client
client.js
~118KBExtracted separately by Vite/Rolldown.
This is good.
Shared dependencies are cached independently.
The Final Architecture
Before:
Everything
↓
User waitsAfter:
Core shell
↓
User requests feature
↓
Load featureThe browser finally stopped being punished for features the user was not using.
The Funny Part
I started this journey because I wanted better UX.
Then I discovered the biggest UX problem wasn't the UI.
It was the application carrying around a backpack full of rocks.
The page builder is still coming.
The security improvements are still coming.
But engineering sometimes means knowing when to stop building and fix the foundation first.
Because adding a fancy new room to a house is great.
But maybe fix the foundation before adding a swimming pool.
And yes.
I still want to plant succulents.
But apparently my version of gardening is:
removing dead dependencies
pruning imports
splitting bundles
watering production builds
evicting lazy architecture decisions
Developers never really retire.
We just trade:
"Why is the plant dying?"
for:
"Why is the bundle dying?"
Same suffering.
Different container.