cd ../blog

Your 25 MB Power Apps Screen Contains 430 Lines of Real Work

What actually takes up space inside a canvas app, measured byte by byte.

</>

Your 25 MB Power Apps Screen Contains 430 Lines of Real Work

What actually takes up space inside a canvas app, measured byte by byte.


Everyone who has worked on a large canvas app knows the feeling. The editor gets sluggish. Publishing takes a minute. Opening the app in Studio means staring at a spinner long enough to check your email. The usual advice is “you have too many controls” — which is true but useless, because nobody tells you why controls are expensive.

So we took a real production app apart and measured it.

The app is a training-and-requests portal: 7 screens, 994 controls, built by a customer, exported in February 2026. Nothing synthetic, nothing pathological. A normal business app that a normal team would call “getting a bit big.”

Here is what is inside it.

The headline numbers

MeasureValue
Packed .msapp on disk3.15 MB
Controls/ folder, uncompressed48.8 MB
Controls994
Persisted property formulas29,196
Formulas a human actually wrote838 (2.9%)
Bytes spent on control template metadata33.4 MB (68.5%)

Read that fourth row again. Of 29,196 property formulas stored in this app, 838 were authored by a person. The other 28,358 are defaults that the serializer wrote down anyway.

And it gets more extreme per screen.

Screen by screen

FileScreenSizeControlsFormulasHuman-authoredTemplate metadata
1.jsonApp0.02 MB21423.3%
4.jsonWelcome0.47 MB215622948.4%
25.jsonCreateNewRequest1.56 MB389154474.0%
63.jsonMyRequests3.54 MB762,0465774.6%
139.jsonCreateNewRequestItemSummit24.91 MB49515,19943067.4%
634.jsonCreateNewRequestLogBook5.55 MB1113,1568768.9%
745.jsonCreateNewRequestTruVault12.39 MB2447,18718568.3%
989.jsonScreen10.33 MB7117490.4%

One screen is 24.91 MB. It holds 495 controls nested up to 9 levels deep. It stores 15,199 property formulas. 430 of them are real.

That is the title of this post, and it is not a rhetorical flourish. It is a RuleProviderType field:

{
  "Property": "Height",
  "Category": "Design",
  "InvariantScript": "40",
  "RuleProviderType": "Unknown"
}

Every persisted property carries this marker. "User" means a human typed it. "Unknown" means the platform filled it in. On the big screen: 430 User, 14,769 Unknown.

Where the 25 MB actually goes

If you break a screen file down by field, one field dominates:

FieldSizeShare
Template16.80 MB86.5%
Rules (the formulas)1.69 MB8.7%
DynamicProperties0.55 MB2.8%
ControlPropertyState0.32 MB1.6%
everything else combined~0.05 MB0.3%

Template is the control’s own definition — its property list, defaults, metadata, manifest. And it is stored on every single control instance.

Not by reference. By value. In full.

Deduplicate those template blobs and 16.80 MB collapses to 552 KB of unique content. A 31× redundancy factor inside a single file.

The part that surprised us: modern controls are the problem

Here is the same screen, grouped by which template each control inlines:

TemplateInstancesBytes eachTotal
PowerApps_CoreControls_TextCanvas12071,3938.17 MB
PowerApps_CoreControls_TextInputCanvas5679,6704.25 MB
PowerApps_CoreControls_ComboboxCanvas2484,0561.92 MB
PowerApps_CoreControls_ButtonCanvas1381,6991.01 MB
PowerApps_CoreControls_DatePickerCanvas1081,7800.78 MB
PowerApps_CoreControls_DropdownCanvas862,3630.48 MB
PowerApps_CoreControls_TabList153,2830.05 MB
PowerApps_CoreControls_CheckboxCanvas147,9210.05 MB
groupContainer2123180.06 MB
attachments123120.00 MB
htmlViewer83100.00 MB
icon42980.00 MB
label13000.00 MB

Look at the two ends of that table.

Classic controls store a 300-byte stub. 212 group containers cost 60 KB in total, because each one just records a template name and version and resolves the real definition from References/Templates.json — one shared copy per app.

Modern controls inline the entire manifest, per instance. One TextCanvas costs 71 KB. A hundred and twenty of them cost 8.17 MB. The definition is byte-identical every time.

So the practical rule is inverted from what you would guess: 212 classic containers are cheaper than 2 modern text labels. The modern control set is a real usability improvement, and it is also a 200×-per-instance storage regression against the classic controls it replaces.

Microsoft already built the fix, and you may already be running it

After measuring the app above we took apart a second, larger one — 6 screens, 1,609 controls, saved August 2026 — and found something that changes the conclusion.

It contains two generations of modern controls, side by side:

GenerationNamingHow the template is storedCost per instance
gen 2PowerApps_CoreControls_TextCanvasinlined into every instance71,393 bytes
gen 3modernTextshared once in References/Templates.json~300 bytes

Gen 3 is a return to the shared-template model the classic controls always used. Same control, ~240× cheaper to store.

Across that app:

InstancesTemplate bytes
gen 2 (PowerApps_CoreControls_*)52434.33 MB
gen 3 (modern*)590.02 MB

266 TextCanvas controls cost 18.11 MB. The modernText template that replaces them is 8,300 bytes, stored once. Migrating the four control types that currently have a gen-3 equivalent would remove roughly 29 MB of that app’s 34 MB of template metadata.

The app already contains both — 266 TextCanvas next to 15 modernText, 112 TextInputCanvas next to 6 modernTextInput. It is mid-migration, and nothing in the product says so.

So far only four controls have a gen-3 version: modernText, modernTextInput, modernCombobox, modernDatePicker. Three are feature-gated (enabledForFeatureGate="Controls.ModernTextInputControl" and friends). There is no gen-3 button, checkbox, toggle, dropdown, or tab list yet.

Two practical consequences. Where a control offers a modern* equivalent, using it is worth two orders of magnitude in stored bytes. And if your app is large and slow, check whether it is carrying gen-2 controls that now have a gen-3 replacement — that is a mechanical win nobody is advertising.

One caution: the migration is not free. Property names change (FontColorColor, FontSizeSize), TabIndex and Tooltip disappear, and on the text input the read/write Value splits into Default and Text. In that app, 94 formulas set .Value and another 187 read it from other controls — 281 edits for one control type.

The second app, by the numbers

MeasureFirst app (Feb 2026)Second app (Aug 2026)
Screens76
Controls9941,609
Controls/ uncompressed48.8 MB64.6 MB
Largest single screen24.91 MB35.07 MB (895 controls)
Persisted formulas29,19654,463
Human-authored838 (2.9%)1,364 (2.5%)
DeserializationLoadTime2.27 s3.15 s
AnalysisLoadTime5.54 s12.31 s

15.5 seconds of parse-plus-analyse before the editor draws anything, and 97.5% of what it is analysing is boilerplate the platform wrote itself. One screen holds 895 controls and 31,551 stored formulas, of which 705 are real.

The trend is the point: both apps land near 97% machine-authored, and the cost scales with the total, not with the part anyone wrote.

The app knows it is slow

Canvas apps record their own performance in Properties.json. This app’s last save:

"DeserializationLoadTime": 2.2710907,
"AnalysisLoadTime":        5.5444732,
"ParserErrorCount":        1,
"BindingErrorCount":       97

7.8 seconds before the editor can show you anything: 2.3 s to parse the JSON, 5.5 s to analyse the formula graph. Plus 97 binding errors the team has been living with.

That AnalysisLoadTime is the one that scales badly. Analysis walks the dependency graph across all 29,196 formulas — and the platform has no way to know that only 838 of them matter, because it wrote the other 28,358 into the file itself.

The most-stored properties

On the big screen, the top of the leaderboard is exactly what you would expect from unconditional serialization:

PropertyInstances stored
Height495
Width495
X494
Y494
ZIndex494
DisplayMode470
BorderColor460
Fill445
BorderStyle437
Visible428
ContentLanguage414

495 controls, 495 stored Height values. Every control on the screen has an explicit height persisted, whether or not anyone chose it.

What to do about it

You cannot change the serializer. You can change what you feed it.

  1. Split the monster screen. 495 controls on one screen is a 25 MB file and 7.8 s of load work every time it opens. Two screens of 250 halve the parse cost of whichever one you are editing.

  2. Count modern controls, not controls. A screen budget of “under 300 controls” is meaningless if 150 of them are TextCanvas. Budget by inlined bytes: ~70–85 KB per modern control, ~300 B per classic one.

  3. Reuse containers, not copies. Galleries replicate their template once (replicationLimit=1); 40 hand-copied cards inline 40 full manifests.

  4. Fix your binding errors. 97 of them is 97 nodes the analyser cannot resolve cleanly on every load.

  5. Stop trusting .msapp size. This app is 3.15 MB packed and 48.8 MB expanded — a 15× ratio, which is precisely what you get when a file is mostly the same bytes repeated. Compression hides the problem from you right up until the editor has to expand it.

  6. Prefer modern* controls where they exist. modernText over PowerApps_CoreControls_TextCanvas is a ~240× reduction in stored bytes per instance. Only four have gen-3 versions so far, but those four are the most-used controls in most apps.

The uncomfortable summary: in this app, 97.1% of what the platform persists is boilerplate, and 86.5% of the biggest file is one control definition photocopied 120 times. The 430 formulas that represent actual engineering are buried in it.

The hopeful footnote: Microsoft has already shipped the architectural fix. It is called modernText, it is 300 bytes per instance instead of 71,393, and it is quietly rolling out behind feature gates while most apps keep paying the old price.


Methodology

Every figure above comes from a single production .msapp exported 2026-02-16 (DocVersion 1.348, MSAppStructureVersion 2.4.0, DocumentAppType: DesktopOrTablet, 1366×768). An .msapp is a ZIP; unpack it and the numbers are directly countable.

unzip -q app.msapp -d app/
python3 - <<'EOF'
import json, os
for f in sorted(os.listdir('app/Controls'), key=lambda x: int(x.split('.')[0])):
    d = json.load(open(f'app/Controls/{f}')); nodes = []
    def walk(n):
        nodes.append(n)
        for c in n.get('Children', []) or []: walk(c)
    walk(d['TopParent'])
    rules = sum(len(n.get('Rules', []) or []) for n in nodes)
    user  = sum(1 for n in nodes for r in (n.get('Rules', []) or [])
                if r.get('RuleProviderType') == 'User')
    print(f, len(nodes), rules, user,
          f"{os.path.getsize(f'app/Controls/{f}')/1048576:.2f} MB")
EOF

Single-app sample, so treat the ratios as indicative rather than universal — but the mechanisms (per-instance template inlining, unconditional default serialization) are structural, not app-specific.