Each Power Apps Component Framework control adds JavaScript to the form load path. Most teams ship a control, see it work, never look at the bundle. Six controls and a chart later, the form takes seven seconds to interactive on a tablet. The fix is not “remove controls”. The fix is a budget and a gate.
What gets shipped
A PCF control’s output is the bundle.js produced by the pcf-scripts toolchain. That file lands in your solution as a webresource and gets loaded on every form that references the control. There is no code-splitting across controls — each control’s bundle is loaded independently — and there is no shared-vendor optimization across controls in the same solution by default.
If your control imports React, you ship React. If your other control also imports React, you ship React again. Multiply by ten controls and you have shipped React ten times in the same form.
The number
Set a hard per-control budget. Our numbers, empirically grounded:
- 50 KB gzipped: an easy control (input mask, simple display).
- 150 KB gzipped: a complex control (data grid with virtualization, calendar).
- 300 KB gzipped: hard ceiling for any single control.
A form should ship under 1 MB gzipped total for PCF controls combined. Above that, on a tablet on a contested network, you cross the threshold where users perceive the form as broken.
Measure before you fix
The pcf-scripts toolchain does not give you bundle analysis out of the box. Wire it in. Edit featureconfig.json to ensure modern bundling, then add a webpack analyzer plugin:
// pcfconfig.json patch
{
"featureFlags": { "pcfAllowCustomWebpack": true }
}
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = (config) => {
if (process.env.ANALYZE === 'true') {
config.plugins.push(new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false
}));
}
return config;
};
Run ANALYZE=true pac pcf build and you get a treemap. The first time you see it, the answers are obvious. Moment.js for one date format. Lodash for one debounce. The Fluent UI Suite import line that pulled in the entire icon set.
The four biggest wins, in order
1. Externalize the platform-provided libraries. React, ReactDOM, and Fluent UI are already in the model-driven host. Mark them external in webpack and let the host serve them. Your bundle drops by 200-400 KB instantly.
config.externals = {
react: 'React',
'react-dom': 'ReactDOM',
'@fluentui/react': 'FluentUIReact'
};
2. Tree-shake icon imports. import { Icon } from '@fluentui/react' will pull thousands of icons. Use the dedicated icon registration:
import { initializeIcons } from '@fluentui/font-icons-mdl2';
initializeIcons(); // host has these; gate behind feature check
3. Replace heavy libraries with native browser APIs. Intl.DateTimeFormat for date formatting, URLSearchParams for query strings, structuredClone for deep copy. Each replacement saves 30-100 KB.
4. Code-split lazy paths. If your control has a rarely-used “export” panel, dynamic-import it. The export module loads only when the user clicks Export. PCF supports dynamic imports as of 2024 toolchain.
CI gate
Run a size check on every PR. Fail the build if the gzipped bundle exceeds the budget.
name: PCF bundle size gate
on: pull_request
jobs:
bundle-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npx pac pcf build --buildMode production
- name: Check gzipped size
run: |
SIZE=$(gzip -c out/controls/*/bundle.js | wc -c)
BUDGET=153600 # 150 KB
if [ "$SIZE" -gt "$BUDGET" ]; then
echo "Bundle $SIZE exceeds budget $BUDGET"
exit 1
fi
Set the budget per control via a config file checked into the repo. The first failure surfaces the discussion: do we lift the budget or refactor the control?
What “lazy” actually buys you
Forms load every visible control in parallel. A bulky control on a tab that the user opens 20% of the time still loads 100% of the time. Two mitigations:
- Mark non-default tabs as “load on focus” in the form designer. The control still loads, but later. Helps perceived performance, not actual data.
- Render an inexpensive placeholder, then dynamically import the heavy logic on first interaction. This actually helps.
Networks of real users
The forms run on tablets in warehouses, phones on cellular, RDP sessions. None of those have the dev laptop’s Wi-Fi. Profile on real conditions. Chrome devtools network throttling at “Fast 3G” is a reasonable approximation. If your control TTI exceeds 3 seconds at that profile, the warehouse hates you.
Solution layering and shared vendors
If you have 10 controls that all need d3 charts, ship d3 once as a separate webresource and reference it via <library> tags in the control manifest. The model-driven host caches webresources across forms. Done right, you trade per-control bundle weight for a one-time vendor load.
<resources>
<code path="index.ts" order="1"/>
<library name="d3" version="7.8.5" order="2">
<packaged_library path="lib/d3.min.js" />
</library>
</resources>
See also
PCF interplay with canvas apps — bundle math differs slightly on canvas because the host loads controls differently.
Pixel notes
When a control is loading lazily, show a skeleton, not a spinner. Skeletons reduce perceived latency and prevent layout shift. A 200 ms skeleton with the right shape beats a 100 ms spinner in user studies, every time.
Bottom line
- PCF controls are isolated bundles — no implicit sharing.
- Set a per-control gzipped budget; we use 50 KB easy, 150 KB complex, 300 KB ceiling.
- Externalize React, Fluent UI, and other platform-provided libraries.
- Replace heavy utility libraries with native browser APIs.
- Gate bundle size in CI; refactor or raise the budget, but never silently regress.