Power Apps engineering

Canvas apps that remember: session state for embedded apps

Keep users’ place and unsaved work across reloads, with a Dataverse session table, explicit app hand-offs and a clear recovery lifecycle.

Two matching app forms connected to a shared Dataverse store illustrate saving a draft and restoring its context.
Conceptual illustration · TuneX365

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.
Persist state outside the iframeThe saved record reaches the HTML host and first canvas app. That app writes drafts and reads recovery data in App Session. It creates a hand-off then opens a second form and app. A daily cleanup job removes expired table rows.01 / THE ARCHITECTURESaved recordModel-driven formHTML web resourceEnvironment + parametersCanvas appGuided workrecordId + appSourceApp Session tableDrafts + hand-offsSave draft JSONRestore on startSecond form + hostExact hand-off IDCreate hand-off,then open formSecond canvas appValidate + continueRead the namedhand-off rowDaily cleanupExpired rowsState is durable only after a successful Dataverse write.
The host passes context. Drafts and hand-off payloads stay in Dataverse; the server job removes expired rows. Open diagram ↗

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.

  1. Validate the scope. Check app, purpose, user, record, status, age and payload version. Restore only on an allowed screen and matching record.
  2. Retire on deliberate exit. Save, Cancel, Close and Back invalidate the draft. Browser crashes and tab closures need expiry and cleanup as a backstop.
  3. 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.
  4. 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 nameSchema nameTypeHolds
Namenew_nameText (100)A readable label, e.g. Draft · scrEditResource. Don't put personal data here.
Appnew_appText (100)The app that should read the row, e.g. ResourceConsole.
Kindnew_kindText (20)draft or handoff.
UserIdnew_useridText (100)The current user’s Microsoft Entra object ID as normalized text.
OriginalRecordIdnew_originalrecordidText (100)The record the work belongs to, e.g. the task on the host form.
TargetRecordIdnew_targetrecordidText (100)Hand-offs only: the record the next app opens, e.g. a contact.
Payloadnew_payloadMultiline textThe JSON snapshot. Raise Max Length; the default is low, and the ceiling is 1,048,576 characters.
Status / Status Reasonstatecode / statuscodeStandardActive = usable. Inactive = retired.
Created On / Modified Oncreatedon / modifiedonStandardUsed 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.

HTML · canvas-session-host.htmlDownload source ↓
<!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>
JavaScript · canvas-session-host.jsDownload source ↓
// 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.");
  }
};
JavaScript · form-session-context.jsDownload source ↓
// 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.

Power Fx · App.FormulasDownload source ↓
// 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.

Power Fx · App.FormulasDownload source ↓
// 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.

Power Fx · scrEditResource › btnSaveDraft.OnSelectDownload source ↓
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
    )
)
Power Fx · each inputDownload source ↓
// 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.

02 / Draft recoveryUser changes are saved by the canvas app. After a host rebuild, the app queries the newest scoped active draft, validates it, restores the known screen and record, and retires the draft on deliberate exit.02 / DRAFT RECOVERYUserHTML hostCanvas appApp SessionEdit fieldsSave screen, record key and fieldsThe host rebuilds the iframe. In-memory state is lost.Start again with saved recordIdRead newest scoped, active, recent draftReturn JSON snapshotValidate version + record; restore fieldsSave, Cancel, Close or BackRetire drafts in the intended scopeRecovery restores the last successful save, not every keystroke.
Restore the last successfully stored draft after a reload, then retire it on a deliberate exit. Open diagram ↗
Power Fx · scrRouter.OnVisibleDownload source ↓
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.

Power Fx · scrEditResource.OnVisibleDownload source ↓
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)
Power Fx · input defaultsDownload source ↓
// 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.

Power Fx · btnSave.OnSelectDownload source ↓
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)
    )
)
Power Fx · btnCancel / icoClose / icoBack OnSelectDownload source ↓
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.

03 / Draft lifecycleThe first successful change creates an Active draft. Further changes update it. Deliberate exits retire it to Inactive. A draft outside the age limit is ignored, without necessarily changing its status. Daily cleanup deletes expired rows.03 / DRAFT LIFECYCLEFirst changeActive draftCreate rowFurther changes: updateInactiveDeliberate exitSave / Cancel / Close / BackIgnored by the readerAge limit exceededStatus may still be ActiveDeleted by cleanupDaily selection on modifiedon
Active rows can become inactive or fall outside the age limit. Scheduled deletion is a separate server operation. Open diagram ↗

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.

04 / App-to-app hand-offAfter the user confirms, the producer writes a hand-off and launches the target form with the exact row ID. The host passes the ID to the consumer, which validates scope and payload and attempts retirement before continuing. Failure returns to normal verification. Strict single use under concurrency requires an atomic server claim.04 / APP-TO-APP HAND-OFFUserCallbackDeskApp SessionTarget form+ hostCase IntakeConfirm the next stepCreate hand-off for target app + contactOpen form with new_hosession = row IDPass recordId + hoSessionRequire exact ID; check scope, age, versionReturn matching payloadAttempt retirement; stop on failureContinue, or start normal verificationConcurrency: separate lookup + update is not an atomic single-use claim.
Pass the exact hand-off ID. Validate before use and stop if retirement fails; concurrent claims need a server operation. Open diagram ↗

Producer: write the row only once the decision is made

Power Fx · Callback Desk › btnContinueToIntake.OnSelectDownload source ↓
// 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

Power Fx · Case Intake › scrStart.OnVisibleDownload source ↓
// 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.

FetchXML · bulk delete criteria
<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.

SymptomCauseRule that prevents it
A later, unrelated visit skipped a mandatory stepThe 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 valuesControls 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 textThe 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 backEach 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 ranThe 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 weeksNames 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.

OptionGood fitBoundary
SaveData / LoadDataSmall, device-local caches.Not centrally queryable; web storage size, privacy and shared-device isolation need care.
Offline-firstStandalone canvas apps that must work offline.Does not cover embedded canvas apps or custom pages.
Custom page / built-in canvas controlPlatform integration and documented navigation or form contracts.Durable drafts still need explicit persistence and restore logic.
Session table + HTML hostA 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, entityName and appSource, 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

How does this apply to your business?

Explore Power Apps and Power Platform consulting, or discuss one workflow and a practical next step.

Talk with Kostya