Home / Articles / Beyond Bundle Size: Finding What Actually Makes Your Web App Slow

This article is published in English.

Beyond Bundle Size: Finding What Actually Makes Your Web App Slow

Why shaving kilobytes rarely fixes a slow app, and how to trace real waiting time across servers, waterfalls, hydration, third-party scripts and images.

3024 words

A familiar scene plays out on frontend teams: weeks go into trimming 40 KB from a JavaScript bundle, while a 900-millisecond database query sits untouched on the critical request path. An icon library gets swapped out, a dependency replaced, another bundler plugin configured, and a debate breaks out over whether a package weighs 18 KB or 12 KB. Then someone loads the app on a real phone over a real network, and it still feels slow.

This article explains why bundle size so often becomes the wrong target, where the waiting actually comes from, and how to run an optimization loop that fixes the largest cost first. Smaller bundles do help, sometimes a lot. But if the page is slow because it waits on the server, blocks rendering, does needless work, fires too many requests or hydrates a huge component tree, cutting another 20 KB will not rescue it.

Why bundle size became the default performance goal

Bundle size is attractive because it is a number. Your build prints something like this:

main.js       842 KB
vendor.js     611 KB
styles.css     94 KB

Someone proposes getting JavaScript under 500 KB, and suddenly the team has a target. You can enforce it in CI, track it across pull requests and celebrate every drop. That feels like engineering progress, and sometimes it genuinely is.

The trouble starts when the number stops being a symptom and becomes the objective. Teams tend to optimize the part of performance they can see most clearly, not the part that costs users the most time. To see why that matters, compare two hypothetical applications.

Application A: small bundle, slow everything else

The first app ships a lean bundle:

JavaScript: 250 KB

Yet everything around it is expensive:

Server response: 1.2s
Database query: 700ms
API calls before rendering: 5
Main-thread work: 900ms

Application B: large bundle, fast path to content

The second app ships almost three times as much JavaScript:

JavaScript: 700 KB

But its server and client both do far less work before the page is useful:

Server response: 150ms
Database query: 40ms
API calls before rendering: 1
Main-thread work: 180ms

Application A does not automatically win. A B-style app will very often feel faster, because it spends roughly a second less on server time, database time, sequential requests and main-thread work, which dwarfs the extra download for most users on a reasonable connection. The principle to anchor every performance discussion on is simple: users do not perceive kilobytes, they perceive waiting.

The page load is a long pipeline, not three steps

Many developers carry a simplified model of loading in their heads:

Download JavaScript
        ↓
Execute JavaScript
        ↓
Page appears

A real request passes through many more stages, each of which can stall:

DNS
 ↓
Connection
 ↓
TLS
 ↓
Request
 ↓
Server processing
 ↓
Database
 ↓
Response
 ↓
HTML parsing
 ↓
CSS processing
 ↓
JavaScript download
 ↓
JavaScript parsing
 ↓
JavaScript execution
 ↓
Hydration
 ↓
API requests
 ↓
Rendering
 ↓
Layout
 ↓
Paint

DNS lookup, connection setup and TLS negotiation happen before your server sees anything. Server processing and database work come next. The browser then parses HTML, processes CSS, downloads, parses and executes JavaScript, hydrates, fires API calls, and only then renders, lays out and paints. And when the user clicks, much of the cycle repeats.

With that many stages, the bundle is just one place where time can vanish. "Make the bundle smaller" is a poor opening move because it assumes the answer before you know where the time is going.

The server may be the slowest part of your frontend

Frontend engineers naturally treat performance as a browser problem. You open DevTools, inspect the Network tab and the JavaScript chunks, and run Lighthouse. But a large share of perceived frontend speed is settled before the browser receives anything useful.

Take a dashboard request:

GET /dashboard

Behind it, the server might do all of this before responding:

→ authenticate user
→ fetch organization
→ fetch permissions
→ query projects
→ query project statistics
→ query notifications
→ calculate recommendations
→ render response

If that chain takes 1.4 seconds, dropping the bundle from 600 KB to 500 KB barely changes the experience, because the first meaningful bytes still arrive at 1.4 seconds. The browser cannot render data it has not received.

A frequent culprit is backend code that awaits independent operations one after another. It starts with fetching the user:

const user = await getUser();

and continues with a chain of further awaits for the organization, projects and notifications, each waiting for the previous one to finish:

const organization = await getOrganization(user.orgId);const projects = await getProjects(organization.id);const notifications = await getNotifications(user.id);

Some of these steps really are dependent: the organization lookup needs user.orgId, and the projects need the organization ID. But anything that does not depend on an earlier result is paying its latency in series for no reason. Where operations are independent, starting them together and awaiting them jointly can cut response time sharply:

const [user, notifications] = await Promise.all([
  getUser(),
  getNotifications()
]);

Notice that the parallel version calls getNotifications() without a user ID. That only works if notifications can be resolved from something already available, such as the session; otherwise that call still has to wait for the user. The general rule is to map the real dependency graph and run each level of it concurrently. For more on choosing between these patterns, see our guide to Promise.all, Promise.race and sequential awaits. A change like this can easily outperform any bundle work.

Waterfalls cost more than bytes

One of the most productive places to begin an investigation is the Network tab, not the bundle analyzer. A very common pattern looks like this:

HTML
 ↓
JavaScript
 ↓
API A
 ↓
API B
 ↓
API C
 ↓
API D

Each step waits for the previous one, and every arrow adds a round trip of latency. Compare it with a design where the first response already includes what the page needs:

HTML
 ↓
API response containing everything required

The second version may transfer more bytes and still be dramatically faster. Bytes and latency are separate problems. A 100 KB response that arrives at once can beat a 20 KB response that needs four sequential round trips before the page does anything useful, particularly on mobile networks where each round trip is expensive.

So when you notice an endpoint returning 300 KB, the reflex is to shrink the payload. That may be worthwhile, but a better first question is why the user needs that response at all before they can interact with the page. The answer often reveals work that can be deferred or removed entirely.

JavaScript work matters more than JavaScript size

A related trap is conflating the size of your JavaScript with the work it causes. A 500 KB bundle is not automatically a disaster. What counts is what the browser must do with it:

  1. Download it.
  2. Parse it.
  3. Compile it.
  4. Execute it.
  5. Build up application state.
  6. Construct component trees.
  7. Attach event handlers.
  8. Hydrate server-rendered markup.
  9. Recalculate layout.
  10. Paint the result.

Two apps with similar bundle sizes can differ enormously in execution cost. Consider a table with 5,000 rows. The data itself is rarely the issue; rendering 5,000 interactive rows' worth of DOM nodes usually is. The fix is not to trim 50 KB of script but to render only the 30 or so rows currently in view. That technique, virtualization, keeps the same application and the same data while potentially removing most of the browser's work.

When hydration becomes the bottleneck

This distinction is especially sharp in React and other component frameworks that render on the server. Server rendering gets HTML on screen quickly, but the browser may then need to hydrate a large component tree before anything responds to input:

HTML arrives quickly
        ↓
User sees content
        ↓
Browser starts hydration
        ↓
Large amount of JavaScript executes
        ↓
Page becomes interactive

The page looks ready well before it is ready. That is why measuring only when content first appears can mislead you. A dashboard with 200 interactive components can produce perfectly reasonable HTML and still burn significant CPU during hydration, leaving clicks unanswered.

The useful question here is not whether the bundle is too big, but why so much code needs to become interactive immediately. Some components may not need client JavaScript at all. Some interactions can be isolated into small islands. Some widgets can load later, and some server-rendered components may never need hydration. Techniques like these, which our overview of partial pre-rendering and concurrent rendering explores, deliver far more than arguing over a 30 KB dependency.

Third-party scripts often outweigh your own code

Before launching a bundle-size campaign, check how much of the code you ship was written by someone else. Typical candidates:

  • analytics
  • chat widgets
  • heatmaps
  • A/B testing
  • advertising
  • customer support tools
  • session recording
  • social embeds
  • marketing pixels
  • consent management

Each can add requests, script execution, layout work and network activity. Ironically, these scripts often face almost no review while engineers spend hours tuning application code. A page might load a set like this:

app.js
analytics.js
chat.js
tracking.js
experimentation.js
heatmap.js

The team celebrates a 70 KB reduction in app.js while the page still executes hundreds of kilobytes of third-party code. That is why performance budgets need a wider scope. Instead of asking how big your bundle is, ask how much code the user's device must process before this page becomes useful. The two questions have very different answers.

Images can dwarf your entire JavaScript budget

Oversized images are another blind spot. A single hero image can outweigh a whole optimized JavaScript chunk:

main.js          180 KB
hero.webp        1.4 MB
product.jpg      900 KB
background.png   2.1 MB

If a pull request removing a 12 KB dependency lands while a 2.1 MB background image ships untouched, the team is performing prioritization theater rather than optimization. Images deserve the same rigor as code:

  • Prefer modern formats such as WebP or AVIF where they are supported and appropriate.
  • Serve responsive sizes instead of one fixed dimension.
  • Never send desktop-sized images to phones.
  • Lazy-load images below the fold.
  • Preload the few images that are genuinely critical.
  • Replace huge background images when a smaller asset does the job.
  • Choose compression levels based on how the image is actually viewed.

Saving 15 KB of JavaScript means little if a phone still downloads 2 MB for an image the user barely notices.

Many performance problems are architecture problems

The deeper you go, the clearer it becomes that many performance issues are not about tuning code at all. They come from how the application is structured. Imagine a product page that needs all of this:

Product
Reviews
Recommendations
Inventory
Shipping estimate
User preferences
Related products

If each piece is fetched separately after the page loads, you can optimize every request and still end up with a slow page. The better move is to decide what the user must see first. The initial response might include only:

Product
Price
Availability
Primary image

Reviews, recommendations and related products can stream in afterward. At that point you are no longer optimizing an implementation; you are redefining what "ready" means for the page, and that is frequently where the largest gains are.

Measure the milestones users actually notice

Serious performance work replaces "how big is the bundle?" with "when can the user do something useful?". That question leads to better metrics.

Time to first useful content

When does the user see the thing they came for? This is often specific to your product: the account balance, the search results, the product photo.

Time to interactive

When can the user interact reliably, without clicks being swallowed by ongoing work? Recent Lighthouse versions no longer include TTI in their score, but the underlying question remains worth tracking for your own pages.

Largest Contentful Paint

When does the main visible content finish rendering?

Interaction to Next Paint

How quickly does the interface respond visually after the user interacts?

Cumulative Layout Shift

Does the layout jump around while the user is trying to read or click?

Total Blocking Time

How long is the main thread blocked by work that prevents the browser from responding to input?

None of these tells the whole story on its own, but together they describe the experience far better than a single line like this:

bundle.js = 487 KB

A bundle figure describes an asset. Performance metrics describe what the user lives through.

An optimization loop worth trusting

When a slow application needs fixing, deleting dependencies should not be the first step. A disciplined loop works better.

1. Reproduce on realistic conditions

Test on representative devices and networks, not only on a fast laptop over office Wi-Fi. Many of your users have neither.

2. Measure to find the slow milestone

Establish where the time goes. Is the dominant problem one of these?

server response?
network?
rendering?
JavaScript execution?
layout?
images?
third-party scripts?

3. Identify the dominant cost

Resist fixing five things at once. Find the single largest contributor.

4. Change one thing

Make the smallest architectural or implementation change that addresses that bottleneck, so you can attribute the result.

5. Measure again

If the improvement does not show up in the numbers, do not assume it worked.

6. Lock the gain in with a regression check

Improvements erode fast. Someone adds a dependency, a product team adds a widget, a component becomes hydrated, a query turns sequential, and three months later you are back where you started. Performance needs automated guardrails in CI and monitoring, not occasional heroic cleanups.

Bundle size still matters, in its place

None of this makes bundle size irrelevant. Large bundles raise download, parse, compile and execution costs, and the penalty is steepest on slow devices and networks. Code splitting, tree shaking, lazy loading and pruning unused dependencies are all valuable. The point is to apply them when the evidence says they are the biggest problem.

A healthy performance review, worked in order of impact, might go like this. First, a slow server response:

Problem:
900ms server response

Fixed by running backend requests in parallel:

Action:
parallelize backend requestsResult:
-420ms

Next, an expensive dashboard hydration:

Problem:
large dashboard hydration

Addressed by deferring components that do not need to be interactive right away:

Action:
defer non-critical interactive componentsResult:
-280ms main-thread work

Then an oversized hero image:

Problem:
hero image is 1.8 MB

Solved with responsive delivery in modern formats:

Action:
responsive WebP/AVIF deliveryResult:
-1.2 MB transferred

Only after all that does a heavy JavaScript dependency reach the top of the list:

Problem:
large JavaScript dependency

Replacing it still saves a worthwhile amount:

Action:
replace dependencyResult:
-60 KB

That fix is still good work. It just belongs fourth in the queue, not first.

Key takeaways

  • Optimize for waiting time, not for the smallest possible artifact. Bundle size is one signal among many.
  • Look at the server and the request waterfall before the bundle analyzer; latency and round trips often cost more than bytes.
  • Measure JavaScript by the work it causes: parsing, execution, rendering and hydration, not just its size.
  • Audit third-party scripts and images with the same rigor as your own code.
  • Redefine "ready" for each page so the critical content arrives first and everything else follows.
  • Work in a loop of reproduce, measure, change one thing, measure again, and protect every gain with a regression check.
  • The most valuable question in performance work is what is making the user wait, and why.