4 Tiny Mistakes That Secretly Destroy Web App Performance
Modern frontend applications are faster than ever, but they are also easier than ever to accidentally slow down.
The dangerous part is that most performance problems do not come from one catastrophic architectural mistake. They usually come from dozens of tiny
decisions that looked completely reasonable at the time. A custom header added during a sprint three years ago. A shared module created temporarily.
A dependency installed for one helper function. A large image that looked great on a designer's MacBook Pro.
Individually, none of these decisions feel serious. Together, they slowly turn an application into something that feels heavy, sluggish and frustrating on real-world devices. And that is the key point many teams miss: developers usually build applications on powerful machines with fast CPUs, stable internet and plenty of memory. Real users often live in a completely different environment. Some are using old Android phones, weak laptops, unstable WiFi or slow mobile networks where every unnecessary request and every extra kilobyte matters.
That is where performance starts becoming a game of milliseconds. And surprisingly often, the biggest problems hide inside things nobody questions anymore.
1. Custom Headers Quietly Triggering Preflight Requests
One of the most overlooked frontend performance problems comes from something that looks completely harmless: custom HTTP headers. A frontend application may send a request like this:
fetch('/api/data', {
headers: {
'X-Feature-Flag': 'enabled'
}
});
At first glance, this looks innocent. It is still just a normal GET request.
But browsers treat many requests with custom headers as non-simple CORS requests. That means before the actual request is sent,
the browser performs an additional OPTIONS request called a preflight request.
So instead of:
- GET /api/data
the browser now does:
- OPTIONS /api/data
- GET /api/data
And suddenly every API call becomes two network calls instead of one. This becomes especially painful during application startup when dozens of requests happen simultaneously. On fast internet connections, the extra delay may feel small. On slower mobile networks, each additional round trip adds noticeable latency. The worst part is that many custom headers are historical leftovers.
In large applications, headers added years ago often survive every refactor because nobody wants to touch them anymore. Sometimes they were originally used for feature flags, debugging, analytics, experiments or temporary backend logic that no longer even exists. Yet the performance cost remains.
I once saw an application where nearly every request triggered preflight checks because of a custom header nobody on the team could explain anymore. Removing that single header noticeably improved startup performance for users on slower connections. Of course, custom headers are sometimes absolutely necessary. Authentication, security, tracing and certain backend workflows genuinely require them. But many frontend-only use cases can be solved differently:
- query parameters
- cookies
- local application state
- configuration fetched once during startup
The important lesson is simple: if your application feels unexpectedly slow, open the Network tab and check whether invisible OPTIONS requests are quietly doubling your API traffic.
2. Code Splitting That Looks Optimized But Changes Nothing
Frontend developers love lazy loading. And honestly, for good reason. Proper code splitting is one of the most powerful ways to improve startup performance in modern applications.
The problem is that many applications technically implement lazy loading while still downloading almost the entire application upfront.
I once audited an Angular application that looked architecturally perfect at first glance. It had feature modules, lazy loading, clean folder structures and modern routing patterns everywhere.
Everything looked optimized. But the application still loaded painfully slowly. When we analyzed the bundle using tools like:
- webpack-bundle-analyzer
- source-map-explorer
- rollup-plugin-visualizer
we discovered the real problem immediately. The application was split into many small modules…
…but almost every module imported the same gigantic shared module containing huge portions of the application. So even though routes were technically lazy loaded, the browser still downloaded most of the application during startup anyway.
This is one of the most common misconceptions around code splitting. Using import() or lazy-loaded routes does not automatically guarantee smaller bundles.
For example:
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.module').then(m => m.AdminModule)
}
looks modern and optimized.
But if AdminModule imports massive shared dependencies used across the entire application, then your lazy-loaded module may still drag enormous
chunks of code into the initial bundle.
The result is an application that appears modular in the codebase while behaving like a monolith in the browser. This is why bundle analysis tools are so important. Developers often trust architecture diagrams instead of checking what the browser is actually downloading. And browsers do not care how beautiful your folder structure looks. They only care about bytes.
3. Runtime Dependencies Nobody Questioned
Another silent performance killer is dependency accumulation.
This usually happens slowly over multiple years of development. One developer installs a date library. Another installs a second one because they prefer different APIs. Somebody imports an analytics SDK for a temporary experiment. Somebody else adds a helper package that quietly pulls in dozens of transitive dependencies. And eventually the application becomes filled with libraries nobody fully understands anymore. I once encountered an enterprise application shipping:
- multiple analytics SDKs
- several icon libraries
- two date libraries
- globally imported Firebase modules
- full Lodash imports
- every Moment.js locale
The funniest part was that the application barely even used dates. But every developer had introduced their preferred solution over time and nothing was ever removed.
One of the classic examples looks like this:
import _ from 'lodash';
instead of:
import debounce from 'lodash/debounce';
That difference may look tiny in isolation, but large applications are built from thousands of tiny decisions exactly like this. And contrary to popular belief, tree shaking is not magic.
Many packages are not fully tree-shakeable. Some dependencies have side effects. Others accidentally pull additional code into shared bundles. Some SDKs initialize globally and remain loaded permanently even when only a tiny feature uses them.
This becomes especially dangerous in long-lived enterprise applications where dependencies accumulate faster than they are audited. That is why strong engineering teams treat new dependencies seriously.
Not because dependencies are bad. But because every dependency becomes part of your runtime cost, bundle size, maintenance burden, security surface and startup performance.
4. Massive Images Destroying Perceived Performance
Everybody knows large images are bad for performance. And yet applications still ship massive background images constantly.
The reason is simple: large images often do not look problematic on developer hardware. On a fast laptop with stable internet, a huge hero image may load almost instantly. But perceived performance changes dramatically on slower devices.
One oversized background image above the fold can completely destroy metrics like Largest Contentful Paint (LCP), especially on mobile networks. I noticed this recently while comparing government websites from different countries. The fastest sites consistently shared similar characteristics:
- minimal visual noise
- simple layouts
- server-side rendering
- limited JavaScript
- aggressively optimized assets
The difference became obvious immediately. Sites focused primarily on delivering information felt dramatically faster because they avoided unnecessary visual weight during startup.
Meanwhile, visually modern sites often loaded huge images, animations, custom fonts and decorative assets before users could meaningfully interact with
the page.
And users absolutely feel that delay.
The tricky part is that large images often survive because they improve screenshots, presentations and stakeholder demos. Nobody notices the performance impact until real users on slower networks start struggling. Fortunately, image optimization has become much easier:
- use AVIF or WebP
- compress aggressively
- lazy load non-critical visuals
- avoid giant hero images above the fold
- preload only critical assets
And honestly, sometimes the best optimization strategy is simply asking:
Do we actually need this image?
Because sometimes the fastest image is no image at all.
Conclusion
Most frontend performance problems are not dramatic engineering failures. They are small decisions that slowly accumulate over time.
- One unnecessary header triggering preflight requests.
- One shared module quietly breaking lazy loading.
- One oversized dependency nobody questioned.
- One background image added because it
looked nicer.
Individually, none of these feel catastrophic. Together, they create applications that feel frustratingly slow especially for users on older hardware or unstable mobile connections. And the scariest part is that almost all of these decisions originally looked completely reasonable when they were introduced.
