Skip to content

Adopting Webpack 5 in Your Bundle

View as Markdown

Engine 7.1.0 compiles your bundle with webpack 5, replacing webpack 4. The upgrade is fully backward compatible: your bundle compiles and renders exactly as before, no changes required.

This guide is for teams who want to go further. Webpack 5 rewards a few patterns much more than webpack 4 did, and your bundle likely has easy wins.

Four changes, in order of impact:

  1. Prefer ES-module (tree-shakeable) dependencies over CommonJS.
  2. Lazy-load heavy, conditional components.
  3. Mark render-once components static.
  4. Keep Node-only code out of client components.

Without any changes, you already get smaller compiled bundles (up to ~20% on large bundles), faster local dev rebuilds, and leaner build logs.

1. Prefer tree-shakeable (ESM) dependencies

Webpack 5’s dead-code elimination analyzes nested exports and drops unused module internals, on a large production bundle we saw ~1,000 modules eliminated that webpack 4 used to ship. Tree-shaking only works on ES modules; a CommonJS require is opaque, so the whole package ships. See Optimize your client-side bundle size by tree shaking your dependencies for the fundamentals.

Steps:

  1. Find your biggest dependencies. Your compiled client bundles are in .fusion/dist/components/ after a local build.
  2. Check whether an ESM build or ESM-native alternative exists for each.
  3. Import specific entry points instead of the package root, where supported.
// Before — CommonJS lodash ships the entire library
import _ from "lodash";
export default ({ items }) => <ul>{_.uniq(items).map(...)}</ul>;
// After — ESM lodash-es ships only `uniq` and its internals
import { uniq } from "lodash-es";
export default ({ items }) => <ul>{uniq(items).map(...)}</ul>;
// Before — pulls all of date-fns via CJS
const dateFns = require("date-fns");
// After — tree-shakeable named import
import { formatDistance } from "date-fns";

The same applies to @wpmedia/* blocks and your own shared packages. Packages that declare "sideEffects": false get shaken hardest. If you maintain internal packages, shipping an ESM build with sideEffects: false is the highest-leverage change you can make.

2. Lazy-load heavy components

Good candidates: anything most page views don’t render or interact with, modals, embeds, below-the-fold widgets, admin-only UI. Dependencies shared across lazy components get deduplicated into shared vendors-*.chunk.js files.

2a. Component.lazy = true (SSR-safe, preferred)

The page still server-renders the full component HTML, no SEO or layout impact, while the client defers downloading the component’s JS until it renders.

import HeavyChart from "@org/charting-library";
const StockChart = ({ symbol }) => <HeavyChart symbol={symbol} />;
StockChart.lazy = true; // or an array of output types, e.g. ["default", "article"]
export default StockChart;

2b. import() / React.lazy (finer-grained, has an SSR caveat)

Use this to split part of a component, e.g. loading a library only after a user interaction.

import { lazy, Suspense } from "react";
const HeavyChart = lazy(() =>
import(/* webpackChunkName: "stock-chart" */ "@org/charting-library"),
);
const StockChart = ({ symbol }) => (
<Suspense fallback={<div className="chart-placeholder" />}>
<HeavyChart symbol={symbol} />
</Suspense>
);

With React.lazy, the server renders the Suspense fallback, not the real content, so:

  • Don’t use it for SEO-critical or above-the-fold content, use Component.lazy (2a) there instead.
  • Make the fallback match the real content’s size to avoid layout shift.
  • import() from an event handler (load-on-click) has no SSR involvement and is always safe.

3. Mark render-once components static

A static component is server-rendered and its HTML preserved on the client, so its code and imports ship zero client-side JavaScript. Combined with tree-shaking, a static component’s dependencies drop out of the client graph entirely.

Good candidates: mastheads, static promos, footers, anything with no client-side interactivity.

import { massiveIconSet } from "@org/brand-assets"; // stays out of client JS
const Footer = () => <footer>{/* ... */}</footer>;
Footer.static = true;
export default Footer;

4. Keep Node-only code out of client components

Webpack 4 silently bundled browser polyfills whenever client-side code imported a Node builtin (crypto, stream, http, etc). Engine 7.1.0 still compiles your bundle the same way, but now warns you when you’re paying for a polyfill. crypto-browserify alone is several hundred KB of client JS, most imports of it are accidental.

Steps:

  1. After a build, check for these warnings:
    • HeavyPolyfillWarning — a Node builtin resolved to a browser polyfill and pulled a large dependency tree into the client bundle.
    • BuiltinFallbackGuardWarning — a server-only builtin (fs, net, …) reached the client. It resolves to an empty module and will throw at runtime. This usually means server-only code leaked into a client component.
  2. Follow the from <module> path in the warning to the offending import and fix it at the source.
// Before — pulls crypto-browserify into the client bundle
const crypto = require("crypto");
const hash = crypto.createHash("sha256").update(id).digest("hex");
// After — the browser's native Web Crypto API, zero bundle cost
// (call from a client-only code path, e.g. useEffect)
useEffect(() => {
window.crypto.subtle
.digest("SHA-256", new TextEncoder().encode(id))
.then(setDigest);
}, [id]);

Also check your dependency choices: if a “browser” flavor exists (e.g. jwt-decode instead of jsonwebtoken for reading tokens), it avoids the polyfill tree entirely.

Skip node_modules transpilation (opt-in)

Bundles that declare an org in blocks.json have always skipped Babel transpilation of their non-org node_modules tree — that’s unchanged, existing behavior. Starting with Engine 7.2.0, bundles that don’t declare an org can opt into that same faster-build behavior. Published node_modules packages ship pre-built code, so transpiling them again only added build time; in our benchmarks, opting in measured roughly 20 to 35% faster compiles on top of the 7.2.0 gains, depending on the bundle. Your results will vary. See Faster local builds in PageBuilder Engine 7.2 for the numbers.

Opt in with either:

  • The COMPILER_SKIP_DEPENDENCY_TRANSPILATION=true environment variable, or
  • arc.config.json: { "compiler": { "skipDependencyTranspilation": true } }

The environment variable takes precedence if both are set. This is off by default in 7.2.x, so most bundles need no changes: .jsx/.tsx files, @arc-fusion/* and @arc-core-components/* packages, and any declared block-org packages are always transpiled regardless of this setting.

Action needed only if, once this exclusion is engaged (by declaring an org, or by opting in), one of your other dependencies ships untranspiled source (e.g. JSX inside plain .js files). Add it to transpileModules in your bundle’s package.json — entries are regular expressions matched against the module’s file path:

{
"transpileModules": ["/node_modules/@your-scope/"]
}

You don’t have to guess ahead of time: if an affected dependency exists, the build fails with an error naming the package and the exact transpileModules line to add. One regex per scope covers every package under it, including transitive dependencies.

See Environment Variables and arc.config.json for the flag reference.

What you get automatically

  • Smaller bundles from tree-shaking, even unchanged bundles shrink.
  • Faster local dev: a persistent filesystem cache across fusion start rebuilds, and watch mode that coalesces multi-file saves into a single rebuild.
  • Cleaner build logs: unactionable noise is gone, real errors and warnings remain.

What hasn’t changed

Some webpack 5 features are intentionally pinned to webpack 4 behavior in this release for compatibility. Changing your bundle to target these won’t do anything yet:

  • Code-splitting thresholds and entry chunks are fixed by the platform; there’s no configuration surface for this in arc.config.json.
  • package.json exports maps are ignored (webpack 4 behavior), so deep imports keep working.
  • Emitted runtime code remains ES5-compatible.
  • Filename-based long-term caching (contenthash) works exactly as before.

These are candidates for future opt-in improvements. If one matters to your team, tell us, that’s what prioritizes the roadmap.

Verifying your improvements

  1. Build locally (npx fusion start or your normal flow) on Engine 7.1.0+.
  2. Compare bundle sizes under .fusion/dist/components/combinations/<output-type>.js before and after your change.
  3. Check the build output for the two warning types from section 4.
  4. For import() changes, confirm the new *.chunk.js files exist and load in the network tab only when the feature renders.