Type something to search...
How to reduce JavaScript bundle size in Astro?

How to reduce JavaScript bundle size in Astro?

To reduce JavaScript bundle size in Astro, ship fewer and smaller islands. Every component with a client:* directive sends its framework runtime and its code to the browser, so the biggest wins come from removing directives from components that don't need interactivity, rewriting small widgets as plain <script> tags, switching from React to a lighter framework such as Preact, and loading heavy code only when it's needed with dynamic import(). Measure first with a bundle visualiser so you know where the bytes actually go, then check the extras you've opted into, such as client-side routing and third-party scripts.

Astro already sends zero JavaScript for components without a client directive, which is why it's often the fastest option for content sites. The JavaScript that does reach the browser is there because you, or an integration, asked for it. In this article you'll learn how to measure what you ship, real numbers for React, Preact and plain scripts from an Astro 7 test build, how to choose the right client directive, how to split code, and which smaller additions are worth reviewing.

Where JavaScript Comes From in an Astro Site

Before cutting anything, it helps to know the sources. In a typical Astro project, browser JavaScript comes from:

  • Hydrated islands: framework components rendered with client:load, client:idle, client:visible, client:media or client:only. Each brings the framework's client runtime plus the component code.
  • The island loader: a small inline script that defines the <astro-island> element and runs the directive logic. It appears on pages with at least one island.
  • Your <script> tags: Astro bundles, minifies and deduplicates them as module scripts.
  • Built-in features you enable: <ClientRouter /> for client-side routing, prefetching, and the server island loader.
  • Third-party scripts: analytics, consent banners, chat widgets and embeds.

The islands architecture is what makes this list short. A component without a directive is rendered to HTML on the server and its JavaScript is thrown away.

Measure Before You Cut

Read the Build Output

After npx astro build, look in dist/_astro/. Each .js file is a chunk. A quick way to see raw and gzipped sizes is a small shell loop:

for f in dist/_astro/*.js; do
  echo "$(wc -c < "$f") $(gzip -c "$f" | wc -c) $f"
done

The first number is bytes on disk and the second is the approximate gzipped transfer size.

Use a Bundle Visualiser

For a breakdown by module, add rollup-plugin-visualizer to Astro's Vite config. Astro 7 runs on Vite 8, and version 7.1.1 of the plugin worked with it in my test:

npm install -D rollup-plugin-visualizer
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  integrations: [react()],
  vite: {
    plugins: [
      visualizer({
        filename: "stats.html",
        template: "treemap",
        gzipSize: true,
      }),
    ],
  },
});

After a build, stats.html appears in the project root. Open it in a browser to see a treemap of every module. Astro runs more than one Vite build, so check that the report you're looking at shows client modules such as react-dom-client.production.js; in my test it did. Remove the plugin when you're done, as it slows down builds.

Real Numbers: React, Preact and a Plain Script

To see the difference choices make, I built the same click counter three ways in an Astro 7.4.2 project and measured the output.

The React version:

// src/components/Counter.jsx
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}
---
// src/pages/react.astro
import Counter from "../components/Counter.jsx";
---

<Counter client:load />

The plain script version, with no framework:

---
// src/components/VanillaCounter.astro
---

<button type="button" class="counter">Clicked <span>0</span> times</button>

<script>
  document.querySelectorAll<HTMLButtonElement>(".counter").forEach((button) => {
    let count = 0;
    const output = button.querySelector("span")!;
    button.addEventListener("click", () => {
      count += 1;
      output.textContent = String(count);
    });
  });
</script>

The results:

VersionJavaScript filesRaw sizeGzipped
React 19 island (@astrojs/react)Runtime, renderer, componentAbout 221 KBAbout 69 KB
Preact island (@astrojs/preact)Runtime, hooks, signals, renderer, componentAbout 25 KBAbout 11 KB
Plain <script>Inlined into the pageUnder 200 bytesNegligible

The React runtime chunk alone was 212,836 bytes, 65,840 bytes gzipped. Pages with islands also carried an inline island loader of about 5 KB. The plain script was so small that Astro inlined it directly into the HTML instead of creating a separate file:

<script type="module">document.querySelectorAll(`.counter`).forEach(e=>{let t=0,n=e.querySelector(`span`);e.addEventListener(`click`,()=>{t+=1,n.textContent=String(t)})});</script>

The framework runtime is a one-off cost. A second React island on the same page reuses the runtime, so you pay mainly for the extra component code. But if a site has one small interactive widget, that one-off cost is the bulk of everything it ships.

Strategy 1: Remove Directives You Don't Need

A component only needs a client directive if it responds to user input or changes after load. Navigation bars, cards, footers, article layouts and most marketing sections don't. Check each client:* in your project and ask what would break without it.

You can keep writing those components in React or Vue. Without a directive, Astro renders them to HTML at build or request time and sends no JavaScript for them at all. The guide on framework components versus Astro components covers when each makes sense.

Strategy 2: Use Scripts for Small Widgets

Menus, accordions, tabs, copy buttons, theme toggles and form enhancements rarely need a framework. A <script> in an Astro component is bundled, typed with TypeScript and deduplicated, so it's included once no matter how many times the component appears on the page.

Some patterns that keep scripts small:

  • Native elements first: <details> and <summary> give you an accordion with no JavaScript. The popover attribute and <dialog> handle many overlays.
  • Custom elements: wrapping behaviour in a custom element scopes it to each instance and runs automatically for elements added later.
  • Event delegation: one listener on a container instead of one per item.

The post on interactivity without a framework has complete examples of each.

Strategy 3: Switch to a Lighter Framework

If you do need a component framework, its runtime is the largest single cost. Preact offers a React-like API in a fraction of the size, and Svelte and Solid compile to small outputs too.

For an existing React codebase, Preact's compatibility layer lets most React components run unchanged:

npx astro add preact
npm uninstall @astrojs/react
// astro.config.mjs
import { defineConfig } from "astro/config";
import preact from "@astrojs/preact";

export default defineConfig({
  integrations: [preact({ compat: true })],
});

With compat: true, imports from react and react-dom are aliased to preact/compat. My React counter built unchanged under Preact with compat enabled, and the total dropped from about 69 KB to about 12 KB gzipped. For new components, importing hooks from preact/hooks directly avoids the compat layer and saves a little more.

Test carefully before switching a large app. Libraries that depend on React internals or very new React features may not work under compat.

Strategy 4: Hydrate Later, or Only Where Needed

When an island is necessary, pick the laziest client directive that still meets the need:

  • client:visible: the component's JavaScript isn't even downloaded until it scrolls near the viewport. Ideal for comment sections, carousels and anything below the fold.
  • client:idle: waits until the browser is idle, which keeps the first seconds free for the visitor's own interactions.
  • client:media: only hydrates when a media query matches, such as a mobile menu that's never needed on desktop.
  • client:load: reserve it for controls that must work immediately.
---
// src/pages/post.astro
import MobileMenu from "../components/MobileMenu.jsx";
import Comments from "../components/Comments.jsx";
---

<MobileMenu client:media="(max-width: 50em)" />
<Comments client:visible />

A lazy directive doesn't reduce total bytes if the visitor eventually triggers it, but it removes them from the critical first load and skips them entirely for visitors who never scroll that far.

Make islands as small as possible, too. Instead of hydrating a whole product page, hydrate only the "add to cart" button and render the rest as static HTML.

Strategy 5: Split Code with Dynamic Imports

Code that's only needed after an interaction shouldn't be in the initial bundle. A dynamic import() inside a script makes Vite split the module into its own chunk, downloaded only when the line runs:

// src/scripts/highlight-table.ts
export function highlightTable(table: HTMLTableElement) {
  for (const row of table.rows) {
    row.addEventListener("mouseenter", () => row.classList.add("is-active"));
    row.addEventListener("mouseleave", () => row.classList.remove("is-active"));
  }
}
---
// src/pages/data.astro
---

<button type="button" id="load-tools">Enable table tools</button>
<table id="data">...</table>

<script>
  const button = document.querySelector<HTMLButtonElement>("#load-tools");

  button?.addEventListener("click", async () => {
    const { highlightTable } = await import("../scripts/highlight-table");
    const table = document.querySelector<HTMLTableElement>("#data");
    if (table) highlightTable(table);
  }, { once: true });
</script>

The build produced a separate highlight-table.[hash].js chunk that the page only requests after the button is clicked. The same pattern works for charting libraries, syntax highlighters, editors and maps, which are often the heaviest dependencies on a page.

Inside framework components, the equivalent tools are the framework's own lazy-loading APIs, such as lazy() with Suspense in React.

Strategy 6: Choose Dependencies Carefully

Small choices in dependencies add up:

  • Import only what you use: import debounce from "lodash-es/debounce" instead of the whole library, or use a native alternative.
  • Prefer platform APIs: Intl.DateTimeFormat and Intl.RelativeTimeFormat replace most date-formatting libraries, and fetch replaces HTTP clients in the browser.
  • Do the work on the server: Markdown rendering, syntax highlighting, date formatting and data transformation can happen in the component frontmatter, so the browser receives the result as HTML.
  • Check the cost before installing: look up the package size, and check whether it's tree-shakable and ships ES modules.

Strategy 7: Review the Extras

Some features add JavaScript on every page once enabled. They're often worth it, but they should be deliberate choices:

FeatureWhat it addsNotes
<ClientRouter />About 16 KB raw, 6 KB gzipped in my testNative CSS cross-document view transitions need no JavaScript
PrefetchingA small scriptEnabled automatically with <ClientRouter />; see prefetching
Server islandsA small loader per deferred componentMoves work off the main request rather than adding much JavaScript
Third-party tagsOften tens or hundreds of KBLoad with defer, delay until interaction, or move to a worker with Partytown

For third-party scripts that must stay, the @astrojs/partytown integration can run some of them in a web worker so they don't compete with your own code on the main thread. Not every script works in a worker, so test each one.

A Checklist for Each Page

When auditing a page, work through this order, which usually gives the largest savings first:

  1. List every island and remove directives from components that don't need interactivity.
  2. Rewrite small widgets as <script> tags or native HTML features.
  3. Replace heavy frameworks for the remaining islands where practical.
  4. Make the remaining directives lazy and the islands smaller.
  5. Split rarely used code with dynamic import().
  6. Audit dependencies and third-party scripts with the visualiser and the Network panel.

FAQ: Reducing JavaScript in Astro

No. Astro components render to HTML and send no JavaScript. JavaScript reaches the browser only from components with a client directive, your own script tags, built-in features you enable such as the ClientRouter, and third-party scripts.

In an Astro 7.4.2 test, a single React 19 counter island shipped about 221 KB of JavaScript, around 69 KB gzipped, almost all of it the React runtime. Additional React islands on the same page reuse that runtime.

Yes. Use the component without a client directive and Astro renders it to static HTML on the server. React is only sent to the browser for components you hydrate.

Most React components work with Preact when you enable compat: true in the Preact integration, which aliases react and react-dom to preact/compat. Libraries that rely on React internals may not, so test before switching.

It doesn't change the size of the bundle, but it delays downloading it until the component is near the viewport. Visitors who never scroll that far never download it, and the first load is lighter for everyone.

Add rollup-plugin-visualizer to the vite.plugins array in your Astro config and run a build. It writes an HTML treemap showing each module's size, including gzipped sizes if you enable that option.


Conclusion

In Astro, every byte of JavaScript is there because something asked for it. That makes reducing it a matter of reviewing those requests: remove client directives that aren't needed, turn small widgets into plain scripts, and when a framework is necessary, choose a light one and hydrate it as late as possible. In my test, those choices made the difference between about 69 KB and almost nothing for the same button.

Measure with the build output and a visualiser, split heavy code behind dynamic imports, and treat built-in extras and third-party tags as deliberate decisions. Done page by page, this keeps an Astro site close to its zero-JavaScript default while still giving visitors the interactivity they need.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper