Minimizing Bundle Size with Tree-Shakable SDKs
This how-to is part of Client-Side SDK Initialization Best Practices. Adding a feature flag SDK to your critical-path bundle can add 50–150 KB before gzip — not because the core evaluation logic is large, but because a single namespace import drags telemetry, background sync loops, and storage adapters along with it. The fix is mechanical: switch to named imports and confirm the SDK declares "sideEffects": false. Everything else in this guide follows from those two facts.
Prerequisites
@openfeature/web-sdk≥ 1.x installed (ESM-first,"sideEffects": falsedeclared)vite-bundle-visualizerorwebpack-bundle-analyzersize-limitor a CI bundle budget check configured in your pipeline
Step-by-Step Procedure
Step 1 — Measure the current SDK footprint
Before changing anything, generate a source-map report so you have a before/after comparison.
# Vite projects
npx vite build && npx vite-bundle-visualizer
# webpack projects
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
In the report, locate @openfeature/web-sdk and note which subdirectories it includes. If you see telemetry/, storage/, or sync/ entries on the critical-path chunk, you have a tree-shaking problem. Record the gzip size of the main entry chunk before proceeding.
Pitfall: run the analysis against a production build (NODE_ENV=production), not a dev server. Dev builds deliberately skip tree-shaking for faster rebuilds.
Step 2 — Replace namespace imports with named imports
A namespace import (import * as SDK) generates an opaque dependency graph that prevents dead code elimination. Replace every occurrence with the named symbols you actually use.
// Before — blocks tree-shaking
import * as FlagSDK from '@openfeature/web-sdk';
const client = FlagSDK.OpenFeature.getClient('my-app');
const value = client.getBooleanValue('web.checkout.express-flow', false);
// After — named import; bundler can prune everything else
import { OpenFeature, type Client } from '@openfeature/web-sdk';
export function getFlag(key: string, defaultValue: boolean): boolean {
const client: Client = OpenFeature.getClient('my-app');
return client.getBooleanValue(key, defaultValue);
}
Apply /*#__PURE__*/ to module-level wrapper calls that have no observable side effects — this signals Rollup and webpack that the call is safe to drop if the return value is unused.
Pitfall: default imports (import SDK from '...') also defeat tree-shaking if the SDK re-exports a class as the default. Always check whether the SDK’s package.json exports map exposes subpath entries — if it does, prefer those over the package root.
Step 3 — Verify the SDK declares "sideEffects": false
Even with named imports, a bundler conservatively retains any module that might run side effects on import unless the package explicitly opts out.
# Check the SDK's package.json directly
node -e "console.log(require('@openfeature/web-sdk/package.json').sideEffects)"
# Expected: false
# If undefined or true: the SDK cannot be fully tree-shaken at the package level
If the SDK returns undefined or true, you have two options: open an issue with the maintainer, or wrap your usage in a thin local adapter that re-exports only the symbols you use and declares "sideEffects": false in its own package.json. That makes the bundler treat the adapter (not the upstream package) as the import boundary.
Pitfall: vendor SDKs that use CommonJS (require()) instead of ESM can never be fully tree-shaken. If @openfeature/web-sdk loads a CJS vendor shim transitively, isolate that shim in a separate async chunk (Step 4) so it does not block the critical path.
Step 4 — Isolate vendor SDK weight into an async chunk
Anything not needed before first paint — telemetry, provider config UIs, analytics bridges — belongs in a lazily loaded chunk. This is especially important if you are lazy-initializing the client SDK after first paint anyway.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes('@openfeature/web-sdk')) {
return 'flag-sdk'; // separate async chunk, not critical path
}
},
},
},
},
});
With this config the SDK chunk downloads in parallel with the main bundle, but it does not block parse or execution of your app code. Combine with bootstrapFlags() called via import(/* webpackChunkName: "flag-sdk" */ './flag-client') to defer the entire init path.
Step 5 — Block regressions in CI
A bundle that was optimized once will drift back without a gate. Add a size budget that fails the PR if the SDK chunk exceeds a threshold.
// .size-limit.json
[
{
"name": "Main entry",
"path": "dist/assets/index-*.js",
"limit": "80 KB"
},
{
"name": "Flag SDK chunk",
"path": "dist/assets/flag-sdk-*.js",
"limit": "45 KB"
}
]
# .github/workflows/bundle-check.yml (relevant step)
- name: Bundle size check
run: npx size-limit
For import-pattern enforcement, add an ESLint rule that blocks wildcard SDK imports before they reach a reviewer:
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@openfeature/web-sdk/telemetry*"],
"message": "Load telemetry only via dynamic import after user interaction."
}
]
}
]
}
}
Pitfall: size-limit measures compressed size. Verify you are comparing gzip-to-gzip numbers across environments; brotli figures differ by 10–20%.
Verification
Re-run the bundle analyzer after all steps and compare with your baseline:
npx vite build && npx vite-bundle-visualizer
Confirm telemetry/ and sync/ subdirectories of @openfeature/web-sdk are absent from the critical-path chunk. The flag SDK chunk should contain only the core evaluator and provider bootstrap code. For secure browser delivery of flag payloads, keep validating that no rule trees or evaluation secrets leaked into the client bundle.
Gotchas & Edge Cases
- Transitive CJS dependencies: if the SDK’s provider depends on a CJS library (e.g., a gRPC codec), that library cannot be tree-shaken regardless of your import style. Check
node_modules/@openfeature/web-sdk/package.jsondependenciesand confirm none are CJS-only before declaring victory. sideEffectsand polyfills: some SDK versions register global polyfills on import. If you see unexpected globals after switching to named imports, inspect the SDK’s entry file for top-levelif (!window.X)blocks — those are side effects that force module retention.- Multiple SDK instances: if both the app shell and a micro-frontend import
@openfeature/web-sdk, each bundle gets its own copy. Share the singleton via Module Federation or a CDN-hosted version rather than duplicating it.
Troubleshooting & FAQ
The bundle analyzer still shows telemetry modules after switching to named imports. Why?
The SDK likely has "sideEffects": true or no sideEffects declaration in its package.json. The bundler cannot safely prune any module from that package. Wrap your usage in a local adapter with "sideEffects": false (see Step 3), or contact the SDK maintainer to add the declaration.
Named imports break at runtime — the symbol is undefined.
The SDK may re-export the symbol from a barrel file that has side effects, causing the bundler to drop it during pruning. Add "sideEffects": ["./src/index.js"] to the local override and re-test. Alternatively, import directly from the subpath if the SDK’s exports map exposes one.
How does this interact with lazy init?
The two optimizations stack. Tree-shaking reduces the SDK chunk size; lazy init via requestIdleCallback delays when that chunk is fetched. Apply both for maximum LCP impact — see lazy-initializing the client SDK after first paint.