Building Canva Apps: Working Within the Iframe Sandbox and Performance Budget
Canva Apps run in a locked-down iframe with a 200 KB budget and a typed RPC layer. This post walks through the architectural constraints — message-passing, design tokens, rate limits — and shows a concrete content-extension pattern that passes review.
29 Dec 2025, 05:08 UTC

The constraint that shapes every Canva app
You have an idea for a Canva extension — maybe a chart generator, a brand-kit importer, or a publish connector. You scaffold the starter template, run npm run dev, and your React app loads inside Canva's editor. Then you hit the first real constraint: the iframe sandbox. Your code runs in a locked-down iframe with a Content Security Policy that blocks eval(), inline scripts, and any network request not explicitly allowed in manifest.json. The editor communicates with your app only through a typed RPC layer (canva-app-sdk) over postMessage. This isn't a typical web app environment; it's a contract.
The thesis is simple: every architectural decision in a Canva app — state management, data fetching, UI theming, publish flows — flows from the iframe sandbox and the 200 KB gzipped initial-payload budget. Understanding the contract lets you design features that pass review and stay performant.
Message-passing as the only bridge
The SDK exposes methods like editor.createDesign, selection.registerOnChange, and publish.requestPublish. Under the hood each call serializes a request, sends it via postMessage, and waits for a response promise. This means:
- All editor interactions are asynchronous. You cannot synchronously read the current selection or layer tree.
- Batching matters. Updating ten elements via
editor.updateElementin a loop incurs ~100 ms per call because each round-trips through the host. The SDK provideseditor.batchto group mutations into a single message. - Type safety is enforced by the SDK's generated TypeScript definitions. If your manifest declares
design:content:read, the SDK will only expose read methods; write methods throw at compile time.
Practical pattern: wrap the SDK in a small adapter that queues mutations and flushes them on requestAnimationFrame. This keeps the main thread under the 50 ms/frame budget Canva enforces.
Design tokens without CSS variables
At load time Canva injects a theme object into window.__CANVA_THEME__. It contains typed tokens for colors, spacing, and typography — for example theme.color.background.primary or theme.typography.heading.fontSize. The tokens automatically switch when the user toggles light/dark mode or applies a brand kit.
However, brand-kit colors arrive only as CSS custom properties (--canva-brand-color-1, etc.), not as typed token objects. If your app needs to render a chart using the user's brand palette, you must parse those properties at runtime:
const brandColors = Array.from(document.styleSheets)
.flatMap(s => Array.from(s.cssRules))
.filter(r => r.selectorText === ':root')
.flatMap(r => Array.from(r.style))
.filter(p => p.startsWith('--canva-brand-color-'))
.map(p => getComputedStyle(document.documentElement).getPropertyValue(p).trim());This workaround adds bundle size and runtime cost. Plan for it early — don't assume the token object covers everything.
Worked example: a content extension that stays under budget
Suppose you're building a "Data Table" content extension. Users insert a table element, edit cells in your panel, and the table serializes to JSON in the design file. On re-open, Canva re-hydrates the element by loading your app version (semantic versioning in manifest.json).
Step 1: Manifest scopes
{
"name": "data-table",
"version": "1.0.0",
"scopes": ["design:content:read", "design:content:write"],
"entryPoints": [{
"type": "content",
"elementType": "data-table",
"panelUrl": "/panel.html"
}]}Step 2: Panel bootstrap (under 200 KB gzipped)
Use a lightweight framework (Preact, Solid, or vanilla) and code-split heavy dependencies. Load Chart.js or a CSV parser only when the user clicks "Import CSV" — not at initial paint.
Step 3: Serialization contract
Your element's JSON schema must be stable across versions. Store only primitive data (strings, numbers, arrays) — no functions or class instances. When Canva re-hydrates, it passes the stored JSON to onElementCreate:
export function onElementCreate(element: DataTableElement) {
const { rows, columns, theme } = element.data;
// Re-render using current theme tokens
renderTable(rows, columns, theme);
}Step 4: Performance marks
Instrument the SDK's performance.mark hooks so Canva's telemetry can see your app's frame time:
import { performance } from '@canva/app-sdk';
function renderTable(...) {
performance.mark('data-table:render:start');
// ... DOM work ...
performance.mark('data-table:render:end');
performance.measure('data-table:render', 'data-table:render:start', 'data-table:render:end');
}If your measure exceeds 50 ms, Canva may throttle or unload the app. Test in DevTools: open the Performance tab, record a session, and look for your custom marks.
Trade-offs you cannot avoid
- No direct layer-tree access. You request changes; the editor applies them. Complex multi-element updates (e.g., "align all selected tables") will feel sluggish. Mitigate by reducing round-trips with
editor.batchand showing optimistic UI. - Rate limits are per-user, not per-app.
asset:writeallows 60 req/min;publishallows 10 req/min. A batch-export feature needs client-side queuing with exponential backoff. Store pending operations inIndexedDBso they survive page reloads. - Marketplace review latency. A security patch requires a version bump and 5–10 business days of review. Ship defensive code (input validation, CSP-compliant asset loading) from day one; you cannot hotfix quickly.
What to verify before you ship
- Run
npx @canva/create-app@latest, thennpm run dev. In Chrome DevTools, check the Network tab for the CSP header:frame-src 'self' https://*.canva.appand confirmwindow.__CANVA_THEME__exists. - Trigger the OAuth consent flow for your declared scopes. Decode the returned access token at jwt.io and verify the
scopeclaim matches your manifest. - Execute a publish flow from your panel. Capture the signed JWT Canva sends to your publish endpoint; decode it and confirm
designId,pages, andexpare present. - Load your app in the editor, open the Performance panel, and record a typical interaction. Ensure no frame exceeds 50 ms and the initial gzipped payload stays under 200 KB.
These checks are not optional — they are the gatekeepers between a working prototype and a publishable app.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.