
Plugin Bundle Size
- 2k installs
- 211 repo stars
- Updated August 4, 2026
- grafana/skills
plugin-bundle-size is an agent skill for shrinking Grafana app plugin module.js with React.lazy, Suspense, and webpack code splitting.
About
The plugin-bundle-size skill optimizes Grafana app plugin initial load by shrinking render-blocking module.js using React.lazy, Suspense, and webpack code splitting. It targets module.js under 200 KB with 15 to 25 JS chunks, applying safe splits in priority order: module.tsx lazy wrappers, route-level lazy imports, extension lazy chunks, then component registries. Steps cover grafana/plugin-actions bundle-size CI, create-plugin updates, codebase analysis for eager imports, named export remapping for lazy(), Faro singleton extraction, and production build verification. Use when developers ask to reduce plugin bundle size, split module.js, improve initial plugin load, or add Suspense lazy loading in Grafana app plugins. Agents should follow the SKILL.md workflow end to end, grounding classification in documented commands, file paths, prerequisites, and troubleshooting notes rather than improvising steps. Reduce Grafana app plugin module.js bundle size with React.lazy, Suspense, webpack code splitting, and bundle-size CI. Invoke when User mentions optimise plugin bundle size, module.js too large, code split plugin, or Suspense lazy loading. Best for Grafana plugin developers optimizi.
- Target module.js under 200 KB with 15 to 25 production JS chunks.
- Priority splits: module.tsx wrappers, routes, extensions, then registries.
- Bundle-size CI via grafana/plugin-actions with PR diff comments.
- import type and dynamic initFaro() keep analytics out of module.js.
- Named export remapping pattern for React.lazy with Grafana components.
Plugin Bundle Size by the numbers
- 1,965 all-time installs (skills.sh)
- +193 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #242 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
plugin-bundle-size capabilities & compatibility
- Capabilities
- module.tsx lazy wrapper refactoring · route and extension code splitting · bundle size ci workflow setup · production chunk measurement
- Use cases
- frontend · devops
npx skills add https://github.com/grafana/skills --skill plugin-bundle-sizeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2k |
|---|---|
| repo stars | ★ 211 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | grafana/skills ↗ |
How do I reduce my Grafana plugin module.js size and split feature code into lazy chunks?
Reduce Grafana app plugin module.js bundle size with React.lazy, Suspense, webpack code splitting, and bundle-size CI.
Who is it for?
Grafana plugin developers optimizing initial load time and render-blocking module.js payload.
Skip if: Skip for datasource-only plugins without app module.tsx entry points or non-Grafana frontend work.
When should I use this skill?
User mentions optimise plugin bundle size, module.js too large, code split plugin, or Suspense lazy loading.
What you get
A production build with module.js under 200 KB, route and extension lazy chunks, and optional bundle-size CI.
- Webpack split chunks
- React.lazy route modules
- Reduced initial module.js payload
Files
Grafana plugin bundle size optimisation
module.js is the render-blocking entry point for every Grafana app plugin. The smaller it is, the less impact the plugin has on Grafana's overall startup time. A well-split plugin should have a module.js under ~200 KB that contains nothing but lazy-loaded wrappers — all feature code loads on demand.
Target: ~15–25 JS chunks total. Fewer means too little splitting; far more (50+) means over-engineering.
Risk levels
Not all splitting opportunities carry the same risk. Apply them in this order:
| Level | What | Risk | Impact |
|---|---|---|---|
| Safe | module.tsx lazy wrappers (Priority 1) | Very low — no behaviour change | Highest — module.js drops 90%+ |
| Safe | Route-level lazy() (Priority 2) | Low — each route is self-contained | High — one chunk per route |
| Safe | Extension lazy() (Priority 3) | Low — extensions are isolated | Medium — independent chunk per extension |
| Moderate | Component registries / tab panels (Priority 4) | Medium — verify Suspense placement | Medium — splits heavy pages further |
| Do not touch | Vendor libraries (@grafana/scenes, @reduxjs/toolkit) | N/A | N/A — webpack splits these automatically |
| Do not touch | Shared utility components (Markdown, Spinner) used across many files | High churn, many callsites | Low — already in shared vendor chunks |
When in doubt, stop after Priority 2. Routes alone typically reduce module.js by 95%+.
---
Step 1: Add bundle size CI reporting (recommended)
Add the grafana/plugin-actions/bundle-size action to get automatic bundle size comparison comments on every PR. This posts a table showing entry point size changes, file count diffs, and total bundle impact.
Root-level plugins (plugin at repo root):
# .github/workflows/bundle-size.yml
name: Bundle Size
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
bundle-size:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
pull-requests: write
actions: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install and build
run: yarn install
- name: Bundle Size
uses: grafana/plugin-actions/bundle-size@a66a1c96cdbb176f9cccf10cf23593e250db7cce # bundle-size/v1.1.0Subdirectory plugins (e.g. plugin/ in a monorepo):
The action's install step runs at the repo root and cannot find yarn.lock in a subdirectory. Work around this by installing deps yourself and symlinking to root:
jobs:
bundle-size:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
pull-requests: write
actions: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ./plugin/.nvmrc
- name: Install dependencies
working-directory: ./plugin
run: yarn install
- name: Symlink plugin to root for bundle-size action
run: |
ln -s plugin/yarn.lock yarn.lock
ln -s plugin/package.json package.json
ln -s plugin/.yarnrc.yml .yarnrc.yml
ln -s plugin/node_modules node_modules
- name: Bundle Size
uses: grafana/plugin-actions/bundle-size@a66a1c96cdbb176f9cccf10cf23593e250db7cce # bundle-size/v1.1.0
with:
working-directory: ./pluginHow it works: On push to main, builds and uploads a baseline artifact. On PRs, compares against it and posts a diff comment. Use workflow_dispatch to generate the first baseline.
Reference: grafana-k8s-plugin workflow
---
Step 2: Detect plugin context
# Confirm this is an app plugin (type: "app" — datasource/panel plugins have different needs)
jq -r '"\(.id) — \(.type)"' src/plugin.json
# Locate the entry point
ls src/module.ts src/module.tsx 2>/dev/null
# Measure the current PRODUCTION bundle size BEFORE making any changes
# Dev builds are unminified and much larger — always measure production
yarn build 2>/dev/null || npm run build
echo "=== module.js ===" && ls -lah dist/module.js
echo "=== all JS chunks ===" && ls -lah dist/*.js | sort -k5 -rh | head -20
echo "=== chunk count ===" && ls dist/*.js | wc -lRecord the baseline. A pre-split plugin commonly has a module.js of 1–3 MB with no other JS chunks.
---
Step 3: Check and update create-plugin
The @grafana/create-plugin tool controls .config/webpack/, .config/jest/, and other build scaffolding. Updating it often unlocks faster SWC compilation and better chunk output.
cat .config/.cprc.json 2>/dev/null || grep '"@grafana/create-plugin"' package.json
npm view @grafana/create-plugin version
npx @grafana/create-plugin@latest updateAfter updating, review the diff (especially .config/webpack/webpack.config.ts) and run a test build. If the plugin has a top-level webpack.config.ts that webpack-merges the base config, review the merge for conflicts.
---
Step 4: Analyse the codebase — find what to split
Do not start implementing until you have read all of these.
# Entry point — look for direct (non-lazy) imports of App, ConfigPage, exposeComponent targets
cat src/module.ts 2>/dev/null || cat src/module.tsx
# Root App component — look for direct page/route imports that should be lazy
cat src/App.tsx src/components/App.tsx src/feature/app/components/App.tsx 2>/dev/null | head -80
# Extension registrations — each should become an independent chunk
grep -r "exposeComponent\|addComponent\|addLink" src/ --include="*.ts" --include="*.tsx" -n
# Exported side-effect singletons (Faro, analytics) — must be extracted before splitting
grep -n "^export const\|^export let" src/module.ts src/module.tsx 2>/dev/null
grep -rn "from '.*module'" src/ --include="*.ts" --include="*.tsx" | grep -v node_modules
# Heavy synchronous imports
grep -rn "from 'monaco-editor\|@codemirror\|d3\b\|recharts\|chart\.js" \
src/ --include="*.ts" --include="*.tsx" | grep -v node_modulesKey rule: If a file is imported by module.ts directly (even transitively), it ends up in module.js. Everything reachable from a lazy boundary becomes its own chunk.
---
Step 5: Implement splits — in priority order
Named vs default exports:React.lazy()requires adefaultexport. Most Grafana plugin components use named exports — use.then()to re-map:
```ts
// Named export
const LazyMyComp = lazy(() => import('./MyComponent').then(m => ({ default: m.MyComponent })));
// Default export
const LazyMyComp = lazy(() => import('./MyComponent'));
```
Priority 1: module.tsx (highest impact, always do this first)
If the entry point is module.ts, rename it: git mv src/module.ts src/module.tsx
Make module.tsx import nothing from feature code except through lazy():
import React, { lazy, Suspense } from 'react';
import { AppPlugin, AppRootProps } from '@grafana/data';
import { LoadingPlaceholder } from '@grafana/ui';
import type { MyExtensionProps } from './extensions/MyExtension'; // import type — erased at compile time
import type { JsonData } from './features/app/state/slice';
// Lazy Faro init — keeps @grafana/faro-react out of module.js
let faroInitialized = false;
async function initFaro() {
if (faroInitialized) { return; }
faroInitialized = true;
const { initializeFaro } = await import('faro');
initializeFaro();
}
const LazyApp = lazy(async () => {
await initFaro();
return import('./features/app/App').then(m => ({ default: m.App }));
});
function App(props: AppRootProps<JsonData>) {
return <Suspense fallback={<LoadingPlaceholder text="" />}><LazyApp {...props} /></Suspense>;
}
const LazyMyExtension = lazy(() =>
import('./extensions/MyExtension').then(m => ({ default: m.MyExtension }))
);
function MyExtension(props: MyExtensionProps) {
return <Suspense fallback={<LoadingPlaceholder text="" />}><LazyMyExtension {...props} /></Suspense>;
}
export const plugin = new AppPlugin<JsonData>().setRootPage(App);
plugin.exposeComponent({ id: 'my-plugin/my-extension/v1', title: 'My Extension', component: MyExtension });Key details:
import typefor props prevents webpack from following the import into the eager bundle- Use
new AppPlugin<JsonData>()if App usesAppRootProps<JsonData>— without the generic,setRootPage()type won't match - Remove any
App as unknown as ComponentClass<AppRootProps>cast — the lazy wrapper is a valid function component
Expected impact: module.js drops from MB range to ~50–200 KB.
Singletons (e.g. Faro): If module.ts has export const faro = initializeFaro(), do NOT keep it as a top-level import. Extract it to src/faro.ts, update all internal imports from '*/module' → '*/faro', then use the dynamic initFaro() pattern above.
---
Priority 2: Route-based splitting in App.tsx
import React, { lazy, Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';
import { LoadingPlaceholder } from '@grafana/ui';
const HomePage = lazy(() => import('../pages/Home'));
const SettingsPage = lazy(() => import('../pages/Settings'));
const DetailPage = lazy(() => import('../pages/Detail'));
function App(props: AppRootProps) {
return (
<Suspense fallback={<LoadingPlaceholder text="" />}>
<Routes>
<Route path="home" element={<HomePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="detail/:id" element={<DetailPage />} />
<Route path="" element={<HomePage />} />
</Routes>
</Suspense>
);
}
export default App;Bypass barrel files: Target the actual component file in the import(), not an index.ts barrel that re-exports multiple things:
// Risky — barrel may pull in other heavy modules
const Catalog = lazy(() => import('features/catalog'));
// Better — only pulls in Catalog's tree
const Catalog = lazy(() => import('features/catalog/Catalog').then(m => ({ default: m.Catalog })));Priority 3: Extension components
Each extension should export default its component. Use fallback={null} for extensions that load quickly:
// src/extensions/MyExtension.tsx
export default function MyExtension(props: MyExtensionProps) {
return <AppProviders><MyExtensionContent {...props} /></AppProviders>;
}Surgical split: If an extension wrapper must stay eager in module.tsx, lazy-load the heavy component it renders:
const HeavyInner = lazy(() => import('components/features/HeavyInner'));
export function MyExtension() {
return <Suspense fallback={<LoadingPlaceholder text="" />}><HeavyInner /></Suspense>;
}Priority 4: Component registries and tab panels
For arrays of objects containing React components (e.g. tab panels), lazy-load each entry. Critical: ensure a <Suspense> boundary exists where the component renders.
const ConfigDetails = lazy(() => import('./ConfigDetails/ConfigDetails').then(m => ({ default: m.ConfigDetails })));
const Overview = lazy(() => import('./Overview/Overview').then(m => ({ default: m.Overview })));
const tabs = [
{ id: 'overview', component: Overview },
{ id: 'config', component: ConfigDetails },
];
// In the parent that renders the active tab:
<Suspense fallback={<LoadingPlaceholder text="" />}>
{ActiveTab && <ActiveTab />}
</Suspense>For datasource plugins (setConfigEditor, setQueryEditor, VariableSupport, AnnotationSupport), see references/datasource-plugins.md.
---
Step 6: Group related chunks if over-splitting
If the build produces more than ~25 JS files, use webpack magic comments:
const FleetList = lazy(() => import(/* webpackChunkName: "fleet" */ '../pages/FleetList'));
const FleetDetail = lazy(() => import(/* webpackChunkName: "fleet" */ '../pages/FleetDetail'));One webpackChunkName per logical feature area. Don't group unrelated pages.
---
Step 7: Measure and verify
yarn build 2>/dev/null || npm run build
echo "=== module.js ===" && ls -lah dist/module.js
echo "=== all JS chunks (largest first) ===" && ls -lah dist/*.js | sort -k5 -rh | head -30
echo "=== chunk count ===" && ls dist/*.js | wc -l| Metric | Target |
|---|---|
module.js size | < 200 KB |
| Total JS chunk count | 15–25 |
| Largest single chunk | < 1 MB |
# Analyse bundle composition if a chunk is unexpectedly large
npx webpack-bundle-analyzer dist/stats.json 2>/dev/null---
Step 8: Test the running plugin
1. Open the plugin in a Grafana instance 2. Navigate to every route — each triggers a new chunk download 3. DevTools → Network → JS: confirm lazy chunks load on navigation, not all upfront 4. Check Console for errors 5. Test any exposeComponent extensions from other Grafana apps
For troubleshooting common issues, see references/troubleshooting.md.
---
References
- grafana-collector-app — app plugin reference implementation
- grafana/plugin-actions — official Grafana plugin CI actions
- Web.dev — code splitting with lazy and Suspense
- SurviveJS — webpack code splitting
- webpack magic comments
Datasource Plugins: setConfigEditor, setQueryEditor, and Support Editors
Datasource plugins (type: "datasource") apply the same lazy-loading pattern to setConfigEditor(), setQueryEditor(), and the editor/QueryEditor fields on VariableSupport and AnnotationSupport. Rename module.ts → module.tsx and lazy-load all four:
// src/module.tsx (datasource plugin)
import React, { Suspense } from 'react';
import { DataSourcePlugin } from '@grafana/data';
import { DataSource, DSOptions } from './datasource';
import { Query } from './types';
import type { KGQueryEditorProps } from './components/QueryEditor';
// Named exports → re-map to default with .then()
const LazyConfigEditor = React.lazy(() =>
import('./components/ConfigEditor').then(m => ({ default: m.ConfigEditor }))
);
const LazyQueryEditor = React.lazy(() =>
import('./components/QueryEditor').then(m => ({ default: m.QueryEditor }))
);
function ConfigEditor(props: DataSourcePluginOptionsEditorProps<DSOptions>) {
return <Suspense fallback={null}><LazyConfigEditor {...props} /></Suspense>;
}
function QueryEditor(props: KGQueryEditorProps) {
return <Suspense fallback={null}><LazyQueryEditor {...props} /></Suspense>;
}
export const plugin = new DataSourcePlugin<DataSource, Query, DSOptions>(DataSource)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor);VariableSupport and AnnotationSupport
For VariableSupport and AnnotationSupport, rename the .ts file to .tsx and assign the lazy-wrapped component:
// src/datasource/VariableSupport.tsx (renamed from .ts)
import React, { Suspense } from 'react';
import type { VariableQueryEditorProps } from './components/VariableQueryEditor';
const LazyVariableQueryEditor = React.lazy(() =>
import('./components/VariableQueryEditor').then(m => ({ default: m.VariableQueryEditor }))
);
function VariableQueryEditorWithSuspense(props: VariableQueryEditorProps) {
return <Suspense fallback={null}><LazyVariableQueryEditor {...props} /></Suspense>;
}
export class MyVariableSupport extends CustomVariableSupport<DataSource, MyVariableQuery> {
editor = VariableQueryEditorWithSuspense;
// ...
}Apply the same pattern for AnnotationSupport.QueryEditor.
Key rule: Use import type for props interfaces — a regular import creates a real module dependency that webpack follows, pulling the component code into the eager bundle and defeating the split.
Troubleshooting: Plugin Bundle Size
| Symptom | Cause | Fix |
|---|---|---|
module.js barely shrank | Entry point still transitively imports feature code | Read module.tsx carefully — any direct import pulls its entire tree in |
| Route shows blank page | Component is rendered outside its Suspense boundary | Add <Suspense> wrapping in the parent, or move the boundary up |
| Extension crashes | Missing AppProviders context | Wrap the default export in the extension file with <AppProviders> |
| Too many chunks (50+) | Every subcomponent split | Use webpackChunkName to group related pages |
module.js barely shrank after rename | Entry point re-exports a singleton (faro, analytics) that pulls in its whole init tree | Extract singleton to src/faro.ts; module.tsx re-exports it with export { faro } from './faro' |
| Circular dependency warning after split | Feature files import from module.ts (e.g. faro) and module.tsx lazy-imports them back | Extract the exported value to a dedicated file (see singleton note in the main skill) |
| Build fails after rename | swc-loader or ts-loader needs tsx support | Ensure tsconfig.json has "jsx": "react-jsx" and "tsx" in the parser config |
lazy() throws "does not provide an export named 'default'" | Component uses a named export, not a default export | Use .then(m => ({ default: m.ComponentName })) |
| Datasource editor blank after split | Suspense missing on VariableSupport.editor or AnnotationSupport.QueryEditor | Wrap the assigned component with a Suspense boundary (see datasource-plugins.md) |
React.lazy not available | Very old React or CommonJS module output | Requires React ≥ 16.6 and esModuleInterop: true in tsconfig |
| Chunks not loading in prod | output.publicPath mismatch | Verify publicPath in webpack config matches public/plugins/<PLUGIN_ID>/ |
ESLint import/no-unused-modules error after rename | ignoreExports glob only matches .ts, not .tsx | Add './src/*.tsx' to ignoreExports in eslint config |
| Chunks cache forever after deploy | chunkFilename missing content hash | Add [contenthash] to output.chunkFilename in webpack config |
setRootPage() type error after adding JsonData generic | AppPlugin not parameterised | Use new AppPlugin<JsonData>() so setRootPage() expects AppRootProps<JsonData> |
| Dev build sizes are huge (multi-MB) | Measuring dev instead of production | Always clean (rm -rf dist node_modules/.cache) and build with --env production for measurements |
rspack compatibility: All React.lazy() / dynamic import patterns work identically with rspack.webpackChunkNamemagic comments are also supported. If the plugin uses.config/rspack/, no changes
are needed to the build config.
Related skills
How it compares
Use plugin-bundle-size for Grafana-specific app plugin bundles rather than generic React bundle guides that omit module.js and Grafana toolchain constraints.
FAQ
What is the module.js target size?
Under 200 KB containing only lazy-loaded wrappers, with 15 to 25 total JS chunks.
Which split has highest impact?
Priority 1 module.tsx lazy wrappers typically drop module.js from MB range to roughly 50 to 200 KB.
Is plugin-bundle-size safe to install?
Review the Security Audits panel on this page before installing in production.