
Pick up where you left off—even when the embedded app has to start again.
Keep a user’s place and the last saved draft in a small Dataverse session table. Restore it deliberately, expire it predictably, and pass a precise hand-off to the next app.
The problem: embedded apps have no memory
A form refresh, a record switch or a rebuilt iframe can interrupt an embedded canvas app. Variables and unsaved inputs live in the running app; they are not a durable recovery mechanism. The last successfully stored snapshot gives the user a place to resume.
The exact lifecycle depends on the host. Do not assume every form save rebuilds the iframe. Test browser refresh, tab navigation, record changes, telephony navigation and session timeout in the application you actually deploy.
A related need is a hand-off: one app completes a step, and a second app needs the relevant context. Pass a row ID in the URL and keep the payload in Dataverse. The ID identifies a candidate; it does not grant permission or prove that a workflow step was completed.
For the underlying host, start with Embed a canvas app in a model-driven form with an HTML web resource.
What Microsoft offers, and where it stops
SaveData and LoadData
These functions save collections to the app host’s local storage. Microsoft documents a 1 MB limit in Teams and web browsers, warns against storing sensitive web data in plain text, and describes user isolation differences on shared devices. They can be useful for local caching, but do not provide centrally managed recovery across devices or apps. Test the exact embedded host and browser; do not assume Studio behavior proves player support. See the function reference.
Offline-first canvas apps
Offline-first supports standalone canvas apps, with its own synchronization model. Microsoft excludes embedded canvas apps and custom pages from that feature. Use it when offline work is the requirement; this online session-table pattern does not replace it. See mobile offline limitations.
Custom pages and the embedded canvas control
Custom pages provide a canvas-style experience within a model-driven app. Microsoft’s known issues describe state loss on return navigation and multi-session tab changes. A custom page can retrieve persisted state from a table too; a web resource is not a prerequisite for durable drafts.
The built-in canvas control provides ModelDrivenFormIntegration for record context and form actions. It does not automatically save every value typed into the canvas app. Choose the host for the integration you need, then design persistence separately. The embedding quick guide compares the main choices.
The pattern: a session table and a web resource host
The design has two parts, each doing one job.
- An HTML web resource hosts the canvas app. It reads the form's record, picks the right app URL for the environment, and passes any parameters the app needs. Because it is your own code, it can pass more than
recordId. - A user-owned Dataverse table, App Session, holds the state. Each row is a JSON snapshot that belongs to one user, one record and one purpose. The canvas app writes it as the user works and reads it whenever the app starts.
The same table serves two purposes:
- Draft: "the user was on screen X, editing record Y, with these field values". It is written by an app and read back by the same app.
- Hand-off: "app A finished step 1 for this record. App B should open at step 2 with this context." It is written by one app and intended for one-time consumption by another.
Stale drafts and unrelated hand-offs can send an app down the wrong path. Four rules keep the lifecycle explicit; the samples below illustrate a single-editor baseline, with the concurrency boundaries called out separately.
- Validate the scope. Check app, purpose, user, record, status, age and payload version. Restore only on an allowed screen and matching record.
- Retire on deliberate exit. Save, Cancel, Close and Back invalidate the draft. Browser crashes and tab closures need expiry and cleanup as a backstop.
- Gate a hand-off before using it. Require its exact ID and stop if retirement fails. Use an atomic server operation when concurrent consumers must be prevented.
- Delete expired state on the server. A cleanup job handles abandoned rows. Its schedule and permissions must match the agreed retention policy.
Step 1 / Create the App Session table
Create a standard User or team-owned table. Keep each user’s rows owned by that user, with no broad sharing. Grant the necessary user-level Read and Write privileges and appropriate Create privilege; leave Delete to a restricted cleanup identity. Verify effective roles, teams, hierarchy access and sharing with two test users. A text UserId filter is application logic, not an authorization boundary.
| Display name | Schema name | Type | Holds |
|---|---|---|---|
| Name | new_name | Text (100) | A readable label, e.g. Draft · scrEditResource. Don't put personal data here. |
| App | new_app | Text (100) | The app that should read the row, e.g. ResourceConsole. |
| Kind | new_kind | Text (20) | draft or handoff. |
| UserId | new_userid | Text (100) | The current user’s Microsoft Entra object ID as normalized text. |
| OriginalRecordId | new_originalrecordid | Text (100) | The record the work belongs to, e.g. the task on the host form. |
| TargetRecordId | new_targetrecordid | Text (100) | Hand-offs only: the record the next app opens, e.g. a contact. |
| Payload | new_payload | Multiline text | The JSON snapshot. Raise Max Length; the default is low, and the ceiling is 1,048,576 characters. |
| Status / Status Reason | statecode / statuscode | Standard | Active = usable. Inactive = retired. |
| Created On / Modified On | createdon / modifiedon | Standard | Used for age limits and cleanup. |
Step 2 / Build the web resource host
Use an HTML web resource plus a form JavaScript library. The form passes its context through getContentWindow(), as in the previous host article. Register initializeSessionCanvas on form OnLoad with execution context enabled, and on the relevant tab’s TabStateChange when lazy loading requires it. Use the actual web-resource control name. Leave automatic record-parameter passing off.
The host uses exact organization origins and preserves the copied canvas app Web link’s existing query parameters. Initialization is idempotent. New records require saving before an app can receive a record ID.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>Canvas session host</title>
<style>
html,body { margin:0; height:100%; font-family:"Segoe UI",sans-serif; }
iframe { width:100%; height:100%; border:0; display:block; }
[hidden] { display:none !important; }
#message { padding:16px; }
</style>
<script src="canvas-session-host.js" defer></script>
</head>
<body>
<p id="message" role="status">Waiting for saved-record context.</p>
<iframe id="canvasApp" title="Canvas workspace" hidden></iframe>
</body>
</html>
// Replace the examples with exact origins and copied app Web links.
const SESSION_APPS = new Map([
["https://YOUR-DEV-ORG.crm.dynamics.com",
"https://apps.powerapps.com/play/e/YOUR-ENV-ID/a/YOUR-APP-ID?tenantId=YOUR-TENANT-ID"],
["https://YOUR-PROD-ORG.crm.dynamics.com",
"https://apps.powerapps.com/play/e/YOUR-ENV-ID/a/YOUR-APP-ID?tenantId=YOUR-TENANT-ID"]
].map(([origin, appUrl]) => [new URL(origin).origin, appUrl]));
const SESSION_GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
let sessionLoadedUrl = "";
window.initializeSessionHost = function (context) {
const frame = document.getElementById("canvasApp");
const message = document.getElementById("message");
function fail(text) {
frame.hidden = true;
frame.removeAttribute("src");
sessionLoadedUrl = "";
message.textContent = text;
message.hidden = false;
}
try {
const recordId = String(context.recordId || "").replace(/[{}]/g, "").toLowerCase();
const appLink = SESSION_APPS.get(new URL(context.clientUrl).origin);
if (!SESSION_GUID.test(recordId)) { fail("Save the record before opening the app."); return; }
if (!appLink) { fail("No app is configured for this environment."); return; }
const url = new URL(appLink);
url.searchParams.set("recordId", recordId);
url.searchParams.set("entityName", context.entityName);
url.searchParams.set("appSource", context.appSource);
url.searchParams.set("source", "iframe");
url.searchParams.set("hidenavbar", "true");
const handOffId = String(context.handOffId || "").replace(/[{}]/g, "").toLowerCase();
if (SESSION_GUID.test(handOffId)) url.searchParams.set("hoSession", handOffId);
else url.searchParams.delete("hoSession");
if (sessionLoadedUrl !== url.href) {
frame.src = url.href;
sessionLoadedUrl = url.href;
}
message.hidden = true;
frame.hidden = false;
} catch (error) {
fail("The app could not be initialized. Check its configuration.");
}
};
// Register on form OnLoad and the relevant TabStateChange.
// Enable "Pass execution context as first parameter".
function initializeSessionCanvas(executionContext) {
const form = executionContext.getFormContext();
const control = form.getControl("WebResource_CanvasSessionHost");
if (!control) return;
const parameter = form.data.attributes.get("new_hosession");
const context = {
clientUrl: Xrm.Utility.getGlobalContext().getClientUrl(),
recordId: form.data.entity.getId(),
entityName: form.data.entity.getEntityName(),
appSource: "ServiceHub", // explicit, stable app identifier
handOffId: parameter ? parameter.getValue() : ""
};
control.getContentWindow().then(function (contentWindow) {
if (typeof contentWindow.initializeSessionHost !== "function") {
throw new Error("The session host is not ready.");
}
contentWindow.initializeSessionHost(context);
form.ui.clearFormNotification("session-host");
}).catch(function () {
form.ui.setFormNotification(
"The canvas workspace could not be initialized. Reload the form to retry.",
"WARNING", "session-host");
});
}
Step 3 / App settings in App.Formulas
Use App.Formulas for named settings and launch parameters; keep mutable state in Set variables. This article uses English-locale formula separators, classic text inputs (Text/Default), a rich-text editor, and example Dataverse display names. Adapt names and choice values to your solution.
// Session settings
fxSessionApp = "ResourceConsole"; // the value this app reads in App Session.App
fxDraftMaxAgeHours = 12; // a draft older than this is ignored
fxHandOffMaxAgeMinutes = 15; // a hand-off older than this is refused
// Launch parameters from the web resource host (always text)
fxRecordId = Lower(Substitute(Substitute(Param("recordId"), "{", ""), "}", ""));
fxAppSource = Param("appSource");
fxHandOffIdParam = Param("hoSession");
// Stable user identifier for application filtering (ownership enforces access).
fxUserId = Lower(Text(User().EntraObjectId));
Step 4 / Save a draft as the user types
A draft is a JSON snapshot scoped to one app, user and host record. Store scalar values and IDs instead of full related records. This baseline assumes one active editor for that scope; multiple browser tabs or devices can create duplicate rows or overwrite newer work. Use a server-enforced key and optimistic concurrency, or separate editing-session IDs, when parallel editing is required.
Add the behavior functions below to App.Formulas after checking that your app supports behavior user-defined functions. They use a Void return type and braces for side effects. Enable formula-level error management. The App object reference describes the syntax; settings and availability can vary by authoring version.
// Baseline: a single active editor per app/user/host-record scope.
// Set gblRestoring during restore so input resets do not save drafts.
SaveDraft(payload: Text): Void = {
If(
!gblRestoring && !IsBlank(fxRecordId),
IfError(
Set(gblDraftRow, Patch(
'App Sessions',
If(IsBlank(gblDraftRow), Defaults('App Sessions'), gblDraftRow),
{
Name: "Draft · " & App.ActiveScreen.Name,
App: fxSessionApp,
Kind: "draft",
UserId: fxUserId,
OriginalRecordId: fxRecordId,
Payload: payload
}
));
Set(gblDraftSavedOn, Now()); true,
Notify("Draft recovery could not be updated. Keep this app open and retry.",
NotificationType.Warning); false
)
)
};
// This baseline retires the current user's whole record scope.
// For parallel editing, retire only the relevant editing-session scope.
CloseDrafts(): Void = {
Set(gblDraftCloseOk, true);
IfError(
ForAll(
Filter('App Sessions',
App = fxSessionApp && Kind = "draft" &&
UserId = fxUserId && OriginalRecordId = fxRecordId &&
'Status Reason' = 'Status Reason (App Sessions)'.Active
) As draft,
Patch('App Sessions', draft, {
Status: 'Status (App Sessions)'.Inactive,
'Status Reason': 'Status Reason (App Sessions)'.Inactive
})
); true,
Set(gblDraftCloseOk, false);
Notify("Draft cleanup failed. Retry before leaving this screen.", NotificationType.Warning); false
);
If(gblDraftCloseOk,
Set(gblDraftRow, Blank());
Set(gblDraft, Blank())
)
};
Each screen that holds unsaved work builds a versioned payload. A screen-level button keeps the JSON in one place. Keep it selectable (not hidden or disabled) if input OnChange formulas use Select; alternatively call the behavior function directly. Show gblDraftSavedOn so the user can see the last successful recovery save.
SaveDraft(
JSON(
{
v: 1, // payload version, so the shape can change later
screen: App.ActiveScreen.Name, // where to come back to
recordKey: Text(locSelected.Resource), // which record this screen was editing
savedOn: Now(),
baseModifiedOn: Text(locSelected.'Modified On', DateTimeFormat.UTC),
fields: {
name: txtName.Text,
program: txtProgram.Text,
street: txtStreet.Text,
city: txtCity.Text,
stateId: Text(cmbState.Selected.State),
zip: txtZip.Text,
phone: txtPhone.Text,
notes: rteNotes.HtmlText
}
},
JSONFormat.Compact
)
)
// txtName.OnChange, txtCity.OnChange, cmbState.OnChange, ...
Select(btnSaveDraft)
Step 5 / Restore the draft when the app starts
Make a small router screen the start screen by setting App.StartScreen = scrRouter. It loads the newest candidate and navigates to a known screen. Guard repeated OnVisible execution. Do not base StartScreen on variables populated asynchronously by OnStart.
If(!gblRouterDone,
Set(gblRouterDone, true);
Set(gblRestoring, true);
Set(gblDraft, Blank());
Set(gblDraftRow, Blank());
If(!IsBlank(fxRecordId),
IfError(
Set(gblDraftRow,
First(SortByColumns(
Filter('App Sessions',
App = fxSessionApp && Kind = "draft" &&
UserId = fxUserId && OriginalRecordId = fxRecordId &&
'Status Reason' = 'Status Reason (App Sessions)'.Active &&
'Modified On' >= DateAdd(Now(), -fxDraftMaxAgeHours, TimeUnit.Hours)
),
"modifiedon", SortOrder.Descending
))
);
Set(gblDraft, If(IsBlank(gblDraftRow), Blank(),
IfError(ParseJSON(gblDraftRow.Payload), Blank()))),
Notify("Draft recovery is unavailable. Start normally or retry.", NotificationType.Warning)
)
);
// Reject unknown versions. Each screen validates its own expected fields.
If(IfError(Value(gblDraft.v), 0) <> 1,
Set(gblDraft, Blank()); Set(gblDraftRow, Blank())
);
Switch(Text(gblDraft.screen),
"scrEditResource",
With({key: IfError(GUID(Text(gblDraft.recordKey)), Blank())},
With({selected: If(IsBlank(key), Blank(), LookUp(Resources, Resource = key))},
If(IsBlank(selected),
Set(gblDraft, Blank()); Navigate(scrHome, ScreenTransition.None),
Navigate(scrEditResource, ScreenTransition.None, {locSelected: selected})
)
)
),
"scrNewResource", Navigate(scrNewResource, ScreenTransition.None),
Set(gblDraft, Blank()); Navigate(scrHome, ScreenTransition.None)
);
Set(gblRestoring, false)
)
Apply the draft only where it belongs
The screen validates the payload version, required field names, screen and record. It checks key presence with ColumnNames, so a deliberately cleared field stays blank. A changed business record raises locRecoveryConflict and disables Save. Provide a review or discard action; clear that flag only after successful reconciliation or draft retirement. This timestamp check detects changes before restore, but a server-side row-version check is still needed to protect a later business save from a concurrent edit.
Set(gblRestoring, true);
UpdateContext({locDraft: Blank(), locRecoveryConflict: false});
If(
IfError(
Value(gblDraft.v) = 1 &&
Text(gblDraft.screen) = App.ActiveScreen.Name &&
Lower(Text(gblDraft.recordKey)) = Lower(Text(locSelected.Resource)) &&
CountRows(Filter(ColumnNames(gblDraft.fields),
Value in ["name", "program", "street", "city", "stateId", "zip", "phone", "notes"]
)) = 8,
false
),
If(
IfError(DateTimeValue(Text(gblDraft.baseModifiedOn)), Blank()) = locSelected.'Modified On',
UpdateContext({locDraft: gblDraft.fields}),
UpdateContext({locRecoveryConflict: true});
Notify("This record changed after the draft was started. Review or discard the draft before saving.",
NotificationType.Warning)
)
);
// Reset every input whose Default or DefaultSelectedItems changed.
Reset(txtName); Reset(txtProgram); Reset(txtStreet); Reset(txtCity);
Reset(cmbState); Reset(txtZip); Reset(txtPhone); Reset(rteNotes);
Set(gblRestoring, false)
// txtName.Default
If(IsBlank(locDraft), locSelected.Name, Text(locDraft.name))
// txtCity.Default
If(IsBlank(locDraft), locSelected.City, Text(locDraft.city))
// cmbState.DefaultSelectedItems: store ids, look the record up again
If(
IsBlank(locDraft),
[locSelected.State],
[LookUp(States, State = IfError(GUID(Text(locDraft.stateId)), Blank()))]
)
// Any in-screen selection change (gallery OnSelect, Back) clears the draft for this screen
UpdateContext({locDraft: Blank()})
// btnSave.DisplayMode: require an explicit review/discard when the record changed.
If(locRecoveryConflict, DisplayMode.Disabled, DisplayMode.Edit)
Step 6 / Retire on every exit
Every deliberate way out of an editing screen retires its draft: Save, Cancel, Close, Back and links to another screen. Pause autosave during exit so queued changes cannot recreate a row after cleanup. Browser closure is not a reliable cleanup event; expiry and the server job handle abandoned rows.
IfError(
Patch(Resources, locSelected, {
Name: txtName.Text,
City: txtCity.Text,
State: cmbState.Selected
// ...
}); true,
Notify("The resource wasn't saved: " & FirstError.Message, NotificationType.Error),
// Business save succeeded; pause autosave before retiring recovery data.
Set(gblRestoring, true);
// Do not repeat the business save if only draft cleanup needs retry.
CloseDrafts();
If(gblDraftCloseOk,
UpdateContext({locDraft: Blank()}); Navigate(scrHome); Set(gblRestoring, false)
)
)
Set(gblRestoring, true);
CloseDrafts();
If(gblDraftCloseOk,
UpdateContext({locDraft: Blank()});
Back();
Set(gblRestoring, false)
)
// If cleanup fails, show a Retry cleanup action; keep autosave paused.
// Resume editing only through an explicit action that clears gblRestoring.
CloseDrafts() attempts to retire all active rows in this single-editor scope, rather than just the row cached locally. ForAll is subject to client row limits and its writes are not one transaction. If duplicates can exceed the query limit, or concurrent tabs matter, move scoped retirement and draft upsert into a server-side operation. Do not promise one row per user/record unless the server enforces that invariant.
Step 7 / Hand off work to another embedded app
Now the second use. A Callback Desk app handles a returned call. When the user chooses "continue to intake", the Case Intake app, embedded on the contact form, should open at the right step with the call's context. Case Intake must not act on a hand-off that wasn't meant for this navigation, for example when the same user opens the same contact two hours later for a different call.
Producer: write the row only once the decision is made
// Write the hand-off only after the user has confirmed the next step.
// A hand-off written "just in case" is the one that gets picked up by mistake later.
IfError(
With(
{
row: Patch(
'App Sessions',
Defaults('App Sessions'),
{
Name: "Hand-off to Case Intake",
App: "CaseIntake",
Kind: "handoff",
UserId: fxUserId,
OriginalRecordId: Lower(Text(locMessage.Message)),
TargetRecordId: Lower(Text(locContact.Contact)),
Payload: JSON({ v: 1, from: "CallbackDesk", messageId: Text(locMessage.Message) }, JSONFormat.Compact)
}
)
},
Launch(
fxModelAppUrl &
"&pagetype=entityrecord&etn=contact&id=" & Text(locContact.Contact) &
"&extraqs=" & EncodeUrl("new_hosession=" & Text(row.'App Session')),
{},
LaunchTarget.New
)
) ,
Notify("The hand-off could not be prepared. Retry before continuing.", NotificationType.Error)
)
Consumer: validate, retire, then use
// Case Intake uses the same settings pattern, with fxSessionApp = "CaseIntake".
// Require an explicit hand-off ID; normal navigation must not pick a stale row.
If(!gblHandOffChecked,
Set(gblHandOffChecked, true);
Set(gblHandOff, Blank());
Set(gblHandOffData, Blank());
IfError(
Refresh('App Sessions');
With({wantedId: IfError(GUID(fxHandOffIdParam), Blank())},
If(!IsBlank(wantedId),
Set(gblHandOff, LookUp('App Sessions',
'App Session' = wantedId && App = fxSessionApp && Kind = "handoff" &&
UserId = fxUserId && TargetRecordId = fxRecordId &&
'Status Reason' = 'Status Reason (App Sessions)'.Active &&
'Created On' >= DateAdd(Now(), -fxHandOffMaxAgeMinutes, TimeUnit.Minutes)
))
)
);
If(!IsBlank(gblHandOff),
Set(gblHandOffData, IfError(ParseJSON(gblHandOff.Payload), Blank()))
),
Set(gblHandOff, Blank());
Notify("The hand-off could not be read. Start with verification.", NotificationType.Warning)
);
If(
IsBlank(gblHandOff) || IfError(Value(gblHandOffData.v), 0) <> 1 ||
Text(gblHandOffData.from) <> "CallbackDesk" ||
IsBlank(IfError(GUID(Text(gblHandOffData.messageId)), Blank())),
Navigate(scrVerify, ScreenTransition.None),
// Single-consumer baseline: stop if retirement fails.
IfError(
Patch('App Sessions', gblHandOff, {
Status: 'Status (App Sessions)'.Inactive,
'Status Reason': 'Status Reason (App Sessions)'.Inactive
});
// Recheck source-record authorization and workflow state before any side effect.
Navigate(scrIntake, ScreenTransition.None),
Notify("The hand-off was not accepted. Start with verification.", NotificationType.Warning);
Navigate(scrVerify, ScreenTransition.None)
)
)
)
Do not fall back to the newest row when the ID is absent. Start with normal verification. A time limit reduces stale-state exposure but does not prove intent. The sample’s separate LookUp and Patch are not an atomic claim: two tabs could both read Active. For strict single use, expose a synchronous server operation that checks owner, purpose, target, expiry and row version, then claims the row conditionally. Keep the business action idempotent. Retiring before navigation can also lose a hand-off if the app closes between those steps; decide whether your workflow needs a claim/complete/retry protocol.
Step 8 / Add a daily cleanup job
Create a recurring bulk deletion job that selects App Session rows whose modifiedon is older than 24 hours, regardless of status. This is an example policy: drafts are read only within 12 hours, and hand-offs within 15 minutes. Configure both age limits and cleanup together, with time for retries. The FetchXML below describes the selection; schedule the job using a supported bulk-delete UI or API, which may require an equivalent QueryExpression.
<fetch>
<entity name="new_appsession">
<attribute name="new_appsessionid" />
<filter>
<condition attribute="modifiedon" operator="olderthan-x-hours" value="24" />
</filter>
</entity>
</fetch>
Run the job daily under an identity with the required bulk-delete and table privileges, and monitor failures. The selection is evaluated when the job executes. A 24-hour threshold plus a daily schedule can leave rows for nearly 48 hours, longer if the job fails. Dataverse auditing, backups and recovery can retain copies separately; agree those policies for any personal data in the payload.
Common pitfalls
These are failure modes to test before release. The fixes below define the behavior the implementation should enforce.
| Symptom | Cause | Rule that prevents it |
|---|---|---|
| A later, unrelated visit skipped a mandatory step | The producer wrote a hand-off before the user decided to continue. The consumer accepted any active row for the contact, at any age. | Write only after the decision. Validate age and id. Use once. |
| Fields showed blank, or showed another record's values | Controls read the global draft on every record and every screen. One draft shape didn't include the fields another screen expected. | Apply a draft only on its own screen and record (locDraft). |
| Saved notes reverted to an older text | The notes box preferred the draft copy over the live record and wrote it back. | Retire on save. Treat live data as the truth once the draft is gone. |
| Rows piled up, and an old one came back | Each Edit created a new row, and exits retired only the remembered one. | Enforce draft uniqueness or define per-session scope. Retire the intended scope. |
| "Consume on restore" never ran | The consume branch checked a payload key that every writer left empty. | Version the payload (v). Test the restore path, not only the save path. |
| Personal data lingered for weeks | Names went into Name and notes into the JSON, and cleanup ran weekly or not at all. | Keep labels neutral, clean up daily, and agree retention with compliance. |
Where this pattern fits
Persisted state is independent of the hosting choice. The benefit is recoverable work across reloads and devices, plus queryable lifecycle data. A web resource adds a flexible launch contract; custom pages and built-in embedded controls can also use a Dataverse session table.
| Option | Good fit | Boundary |
|---|---|---|
| SaveData / LoadData | Small, device-local caches. | Not centrally queryable; web storage size, privacy and shared-device isolation need care. |
| Offline-first | Standalone canvas apps that must work offline. | Does not cover embedded canvas apps or custom pages. |
| Custom page / built-in canvas control | Platform integration and documented navigation or form contracts. | Durable drafts still need explicit persistence and restore logic. |
| Session table + HTML host | A reusable standalone app with custom launch parameters and centrally managed recovery. | Online Dataverse writes, table permissions, retention, lifecycle code and concurrency handling. |
What the custom host contributes
- Explicit launch context: an app ID, saved record and an exact hand-off ID, rather than a large payload in a URL.
- Predictable initialization: exact environment mapping, saved-record checks and idempotent loading.
- Independent reuse: the standalone app can also support direct launch if it handles missing parameters.
The host does not automatically provide two-way form synchronization. Do not assume a standalone canvas app can call its parent frame or emit arbitrary messages without a supported integration mechanism. Keep the support boundaries from the HTML host article.
What it costs to maintain
- Draft writes consume Dataverse requests and can be throttled. Debounce carefully and make save failure visible.
- Recovery adds startup queries and parsing; measure latency in the target environment rather than promising a fixed load time.
- Security roles, schema, payload versions, cleanup, error paths and concurrent sessions need ownership.
- Embedded authentication and browser cookie restrictions still apply; persistence does not resolve them.
- The pattern requires connectivity. It does not replace an offline app.
Checklist
- Rows are user-owned; effective roles, teams and sharing are tested with two users. Delete belongs to the cleanup identity.
- Payload Max Length raised. No personal data in
Name. - Host web resource passes
recordId,entityNameandappSource, and waits for the form. - Every read filters on app, kind, user, record, Active and age.
- The draft is applied through a screen variable, only on its own screen and record.
- Every app-controlled exit pauses autosave, retires drafts and handles cleanup failure.
- Hand-offs require an exact ID, full scope and version validation, and successful retirement; concurrent single use is enforced server-side when required.
- Payload version, field shape, allowed screens, record access and stale business data are validated before restore.
- The cleanup threshold and schedule match the actual retention policy, including audit and backup retention.
- Restore, hand-off and stale-row cases are tested, not only the happy path.
References
- SaveData, LoadData and ClearData functions
- Understand canvas-app variables
- Limitations of offline-first canvas apps
- Develop offline-capable canvas apps
- Custom pages overview · Known issues
- Guidelines for embedded canvas apps · ModelDrivenFormIntegration
- Embed canvas apps in your websites
- Param and Launch · App object (Formulas, StartScreen)
- JSON · ParseJSON · Working with JSON
- getContentWindow and idempotent initialization
- Allowed form query-string parameters
- Dataverse optimistic concurrency
- ColumnNames and Column for dynamic payloads
- User and Microsoft Entra object ID