npx skills add ...
npx skills add grafana/skills --skill plugin-bundle-size
Optimise Grafana app plugin bundle size using React.lazy, Suspense, and webpack code splitting. Use when the user asks to reduce plugin bundle size, optimise module.js, add code splitting, improve initial plugin load performance, split plugin chunks, lazy load plugin pages, or help implement lazy loading in a Grafana app plugin. Triggers on phrases like "optimise plugin bundle size", "module.js is too large", "plugin is slow to load", "code split the plugin", "reduce initial JS payload", or "help me with Suspense in my plugin".
npx skills add grafana/skills --skill plugin-bundle-size
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.
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%+.
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):
Subdirectory 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:
How 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
Record the baseline. A pre-split plugin commonly has a module.js of 1–3 MB with no other JS chunks.
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.
After 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.
Do not start implementing until you have read all of these.
Key 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.
Named vs default exports:
React.lazy()requires adefaultexport. Most Grafana plugin components use named exports — use.then()to re-map:
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():
Key details:
import type for props prevents webpack from following the import into the eager bundlenew AppPlugin<JsonData>() if App uses AppRootProps<JsonData> — without the generic, setRootPage() type won't matchApp as unknown as ComponentClass<AppRootProps> cast — the lazy wrapper is a valid function componentExpected 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.
Bypass barrel files: Target the actual component file in the import(), not an index.ts barrel that re-exports multiple things:
Each extension should export default its component. Use fallback={null} for extensions that load quickly:
Surgical split: If an extension wrapper must stay eager in module.tsx, lazy-load the heavy component it renders:
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.
For datasource plugins (setConfigEditor, setQueryEditor, VariableSupport, AnnotationSupport), see references/datasource-plugins.md.
If the build produces more than ~25 JS files, use webpack magic comments:
One webpackChunkName per logical feature area. Don't group unrelated pages.
| Metric | Target |
|---|---|
module.js size | < 200 KB |
| Total JS chunk count | 15–25 |
| Largest single chunk | < 1 MB |
exposeComponent extensions from other Grafana appsFor troubleshooting common issues, see references/troubleshooting.md.
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: ./plugin# 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 -lcat .config/.cprc.json 2>/dev/null || grep '"@grafana/create-plugin"' package.json
npm view @grafana/create-plugin version
npx @grafana/create-plugin@latest update# 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_modules