Most Canvas Apps silently corrupt data the moment two users edit the same record. This deep-dive lesson teaches you to implement pessimistic record locking, ETag-based conflict detection, and Azure SignalR push notifications — building a complete real-time collaboration layer that works at production scale.

Picture this: your operations team has finally moved off that creaky Excel spreadsheet where three people are editing the same row simultaneously, overwriting each other's work. You've built a slick Canvas App backed by Dataverse, everyone's using it, and then the complaints start rolling in. Sarah in Dallas just overwrote the vendor negotiation notes that Marcus in Chicago spent twenty minutes typing. Meanwhile, the warehouse manager in Memphis is staring at inventory counts that haven't refreshed since she opened the app forty-five minutes ago. You've traded one problem for another — and this one is harder to see.
Real-time collaboration in Canvas Apps is one of those topics that sounds straightforward until you actually build it. Power Apps gives you a relatively thin abstraction layer over your data sources, which means the platform won't magically handle concurrent edits, stale data, or edit conflicts for you. You have to design these patterns yourself, and designing them well requires understanding how Canvas Apps actually evaluate formulas and refresh data, how Power Automate can serve as an event backbone, and how SignalR — a technology most Power Apps developers haven't touched — can push live state changes directly into your app without polling.
By the end of this lesson, you'll have a complete mental model and working implementation for three interlocking collaboration patterns: pessimistic record locking (preventing simultaneous edits), optimistic conflict detection (catching concurrent changes when locking isn't feasible), and live data refresh via SignalR-backed notifications through Azure API Management. You'll understand the architecture trade-offs at each layer, the failure modes that will bite you in production, and how to make these patterns feel invisible to users rather than cumbersome.
What you'll learn:
This lesson assumes you're comfortable with intermediate-to-advanced Canvas App development. Specifically, you should already know:
Patch, Filter, LookUp, and UpdateContextYou'll need: a Power Apps environment with Dataverse, a Power Automate premium license (for HTTP actions), and an Azure subscription for SignalR Service and API Management (a free tier Azure account works fine for the SignalR piece).
Before we write a single line of Power Fx, we need to understand the underlying problem clearly. Canvas Apps are pull-based by default. When your app loads, Power Apps executes your data queries and caches the results in memory. Unless you explicitly trigger a Refresh() call or re-evaluate a formula that hits the data source, your users are looking at a snapshot. A snapshot from whenever they last caused a data fetch.
This is fine for read-heavy apps where data changes slowly. It becomes a disaster the moment two people are editing the same record.
Consider a Dataverse table called ServiceOrders with fields like so_assignedtechnician, so_status, and so_notes. User A opens a service order record in your app at 2:00 PM. The app fetches the record and stores it in a local variable. User B opens the same record at 2:01 PM. At 2:05 PM, User A saves their changes via Patch. At 2:06 PM, User B saves their changes. Because User B's app loaded the original record at 2:01 PM, their Patch call happily overwrites User A's changes. Neither user sees an error. The data is silently corrupted.
Dataverse actually has a built-in mechanism to prevent this — the @odata.etag concurrency token — but Canvas Apps don't expose this through their native connectors. You have to reach down to the underlying API yourself.
The three patterns we'll implement address this problem at different layers:
These patterns complement each other. You'll rarely implement just one.
Pessimistic locking means we explicitly track who holds the edit rights to a record. Before a user can edit, they must successfully acquire the lock. If someone else holds it, they get a read-only view with a message indicating who's editing.
In Dataverse, add these fields to your target table (ServiceOrders in our example):
so_lockedbyprincipalid — Text field, 36 characters (stores the AAD Object ID of the locking user)so_lockedbyname — Text field (display name, for the "Marcus is editing this record" message)so_lockedattimestamp — Date/Time field (UTC, for expiry calculation)so_locktoken — Text field, 36 characters (a GUID we generate to prevent race conditions in the unlock flow)Why store the lock token separately from the user ID? Because you might have the same user with the app open in two tabs. The lock token ensures only the specific session that acquired the lock can release it. This matters more than it sounds.
In your Canvas App, when a user taps "Edit" on a service order, you don't immediately drop them into edit mode. Instead, you call a Power Automate flow that attempts to acquire the lock atomically.
Here's the flow structure in Power Automate:
Flow: AcquireRecordLock
recordId (GUID), requestorObjectId (AAD Object ID), requestorDisplayName (string)The flow body:
recordIdso_lockedbyprincipalid empty, OR is the current time more than 15 minutes past so_lockedattimestamp?{ "acquired": true, "lockToken": "..." }{ "acquired": false, "lockedBy": "Marcus Chen", "lockedAt": "2024-01-15T14:00:00Z" }The critical detail in step 2 is the atomicity problem. What if two users check the lock at the same moment and both see it as empty? They'd both proceed to write their lock, and the last one to write wins — creating a race condition.
Power Automate doesn't give you database-level atomic compare-and-swap operations directly, but you can approximate this by using Dataverse's native conditional update via the HTTP action. The trick is using the If-Match OData header with a specific ETag value. Let's look at what that action configuration looks like.
In Power Automate, add an HTTP action configured as follows:
Method: PATCH
URI: https://[yourorg].api.crm.dynamics.com/api/data/v9.2/so_serviceorders([recordId])
Headers:
Content-Type: application/json
OData-MaxVersion: 4.0
OData-Version: 4.0
Prefer: return=representation
If-Match: *
Body:
{
"so_lockedbyprincipalid": "@{triggerBody()?['requestorObjectId']}",
"so_lockedbyname": "@{triggerBody()?['requestorDisplayName']}",
"so_lockedattimestamp": "@{utcNow()}",
"so_locktoken": "@{guid()}"
}
Wait — If-Match: * just means "update if the record exists," which doesn't prevent the race condition. You need a tighter check. Before the PATCH, GET the record and capture its current ETag:
GET https://[yourorg].api.crm.dynamics.com/api/data/v9.2/so_serviceorders([recordId])?$select=so_lockedbyprincipalid,so_lockedattimestamp
Headers:
OData-MaxVersion: 4.0
OData-Version: 4.0
The response headers will include OData-EntityId and the body will have @odata.etag. Capture that ETag. Now your PATCH uses:
If-Match: [captured ETag value]
If another request modified the record between your GET and your PATCH, the ETags won't match and Dataverse will return a 412 Precondition Failed — your flow catches this and returns { "acquired": false, "reason": "concurrent_attempt" }. The Canvas App retries automatically.
This is the key insight about pessimistic locking in Dataverse: The native Canvas App connector hides ETags from you. Dropping to the HTTP action in Power Automate gives you access to the full OData protocol, including conditional writes. This pattern appears again in conflict detection.
Back in Power Apps, the "Edit" button's OnSelect should look like this:
// Generate a session identifier stored in App.OnStart
// UpdateContext({ varSessionId: GUID() }) ← in App.OnStart
Set(varLockPending, true); // Show loading indicator
Set(
varLockResult,
AcquireRecordLock.Run(
ThisItem.so_serviceorderid,
User().ObjectId,
User().FullName
)
);
If(
varLockResult.acquired = true,
// Success path
UpdateContext({
locEditMode: true,
locLockToken: varLockResult.lockToken,
locRecordSnapshot: ThisItem // Store original values for conflict detection later
}),
// Failure path
UpdateContext({
locEditMode: false,
locLockMessage: "This record is being edited by " & varLockResult.lockedby
})
);
Set(varLockPending, false)
The locRecordSnapshot variable is doing double duty here — it's not just for pessimistic locking, it's also the baseline we'll use for conflict detection in Pattern 2. Store the full record, not just the ID.
Here's the failure mode that kills most lock implementations: the user opens a record in edit mode, walks away from their desk, and the lock never expires because their browser is still open and happy. Or worse — their browser crashes mid-edit and the lock token is lost.
You need two things: an automatic expiry time (15 minutes is reasonable for most business contexts), and a heartbeat that resets that expiry while the user is actively editing.
In Canvas Apps, there's no native setInterval. You can fake a heartbeat using a Timer control set to repeat every 60 seconds. Place a Timer on your edit screen with these properties:
Duration: 60000
Repeat: true
AutoStart: locEditMode
OnTimerEnd:
If(
locEditMode,
// Call the heartbeat flow
RefreshLock.Run(locCurrentRecordId, locLockToken)
)
The RefreshLock Power Automate flow simply updates so_lockedattimestamp to the current UTC time, but only if the so_locktoken field matches the provided token. This prevents a user who lost their lock from unknowingly refreshing a lock now held by someone else.
Warning: Timer controls in Canvas Apps consume resources even when the screen isn't visible if you set
AutoStart: trueand the timer fires before the screen is navigated away from. Always setAutoStartto a condition, not a constanttrue, and reset it when leaving the edit screen.
Releasing the lock should happen in three scenarios: the user saves, the user cancels, or the app is closed/navigated away. The first two are easy — your Save and Cancel buttons call a ReleaseLock flow.
The third is the hard one. Canvas Apps don't have a reliable OnClose or OnUnload event. The closest you have is the App.OnClose property (available in recent platform versions), but it's not guaranteed to fire during a browser crash.
This is exactly why the 15-minute expiry with heartbeat is non-negotiable, not optional. The expiry is your safety net for the crash scenario. Set it appropriately for your workflow: for a 5-minute form, 10 minutes of expiry gives reasonable breathing room. For a complex multi-section edit, 30 minutes might be right.
Pessimistic locking is great, but it has costs. Users waiting on locks get frustrated. Locks that crash with the browser leave records inaccessible. For some workflows — particularly read-heavy ones with occasional quick edits — optimistic concurrency is a better fit. Let users edit freely, but catch collisions before they corrupt data.
The core of optimistic concurrency is: before saving, verify that the record hasn't changed since you loaded it. Dataverse's ETag mechanism is perfect for this.
The problem: the Canvas App's native Dataverse connector doesn't expose ETags. You have to load the record via a Power Automate flow (or custom connector) that hits the Dataverse API directly and returns the @odata.etag value along with the record data.
Create a GetRecordWithETag flow:
GET https://[yourorg].api.crm.dynamics.com/api/data/v9.2/so_serviceorders([recordId])?$select=so_assignedtechnician,so_status,so_notes,so_modifiedon
Headers:
OData-MaxVersion: 4.0
OData-Version: 4.0
Parse the response and return both the record fields AND the @odata.etag header value. Store this ETag in a Canvas App variable when the user opens a record for editing:
// In your record detail screen's OnVisible
Set(
varRecordWithMeta,
GetRecordWithETag.Run(ThisItem.so_serviceorderid)
);
UpdateContext({
locCurrentETag: varRecordWithMeta.etag,
locOriginalNotes: varRecordWithMeta.so_notes,
locOriginalStatus: varRecordWithMeta.so_status
})
Now when the user hits Save, instead of using Patch directly, you call a ConditionalSave Power Automate flow that passes the ETag along with the new values:
PATCH https://[yourorg].api.crm.dynamics.com/api/data/v9.2/so_serviceorders([recordId])
Headers:
Content-Type: application/json
OData-MaxVersion: 4.0
OData-Version: 4.0
If-Match: [locCurrentETag value]
Body:
{
"so_notes": "[new notes value]",
"so_status": "[new status value]"
}
If the record has been modified since the user loaded it, Dataverse returns HTTP 412 Precondition Failed. Your flow catches this with a Configure Run After setting on a condition action and returns { "saved": false, "reason": "conflict" }.
The Canvas App receives this response and now has to do something useful with it — which is where most implementations drop the ball.
Don't just show "Someone else modified this record. Your changes were discarded." That's lazy and maddening. Show the user what actually changed.
When a 412 conflict occurs, your ConditionalSave flow should do more work before returning:
In Power Fx, you can then present a side-by-side comparison:
// After receiving conflict response
If(
varSaveResult.saved = false && varSaveResult.reason = "conflict",
UpdateContext({
locShowConflictDialog: true,
locConflictCurrentValues: {
notes: varSaveResult.currentnotes,
status: varSaveResult.currentstatus,
modifiedby: varSaveResult.currentmodifiedby
},
locMyChanges: {
notes: txtNotesInput.Text,
status: ddlStatusInput.Selected.Value
}
})
)
Your conflict dialog (a popup container or overlay) shows three columns: "Original Value" (from locOriginalNotes, etc.), "Their Changes" (from locConflictCurrentValues), and "Your Changes" (from locMyChanges). Give the user three buttons:
If-Match: * (match any ETag, bypass concurrency check)The "Keep Mine" option should be clearly labeled as overwriting the other person's changes. This transparency prevents angry escalations later.
Architecture note: "Merge Manually" is the hardest option to implement but the most valuable for text-heavy fields like notes. You can approximate a simple merge by concatenating values with a timestamp separator. True three-way merge is beyond Canvas Apps' native capabilities without a backend service — this is a legitimate place to invoke an Azure Function.
Some older Dataverse configurations or third-party data sources don't support ETags properly. In those cases, you can fall back to modifiedon timestamp comparison, which is less precise but usually adequate.
When loading the record, store its modifiedon value. Before saving, fetch the current modifiedon and compare:
// Before saving
Set(
varCurrentModified,
LookUp(ServiceOrders, so_serviceorderid = locCurrentRecordId, so_modifiedon)
);
If(
varCurrentModified <> locOriginalModifiedOn,
// Conflict detected — show resolution UI
UpdateContext({ locShowConflictDialog: true }),
// No conflict — safe to save
Patch(ServiceOrders, locCurrentRecord, { so_notes: txtNotesInput.Text, ... })
)
This has a race window (the check and the save aren't atomic), but for low-volume collaborative editing it's usually acceptable. The ETag approach is always preferred when possible.
The previous two patterns manage write conflicts. This pattern addresses the read staleness problem — users seeing outdated data. The naive solution is polling: Timer.OnTimerEnd: Refresh(ServiceOrders). But polling has serious problems at scale.
If 50 users are polling Dataverse every 30 seconds, that's 100 API calls per minute just for refresh. Dataverse has API limits (typically 6,000 requests per 5 minutes per user, and aggregate limits per organization). More importantly, polling creates unnecessary load even when nothing has changed.
The elegant solution is push-based notification: when data changes, the server tells interested clients, and only then do those clients refresh. Azure SignalR Service gives you this capability.
Here's how the pieces fit together:
The Canvas App connects to SignalR through a custom connector that wraps the SignalR REST API. This is the part that surprises most people — you don't need native WebSocket support in Canvas Apps because Azure SignalR Service provides a REST-based negotiation and message delivery mechanism.
In the Azure portal, create a new SignalR Service resource. Choose the Serverless service mode — this is critical. Classic mode requires a hub server, which adds complexity you don't need. Serverless mode lets clients connect directly and receive pushes from your backend.
Set the pricing tier to Free for development (20 concurrent connections, 20,000 messages/day). For production, you'll need at least the Standard tier.
After creation, note down two things from the "Keys" blade:
SignalR clients don't connect directly to the service endpoint. They first call a "negotiate" endpoint to get a temporary access token and hub URL. You need to expose this negotiate endpoint from Azure Functions.
Create a new Azure Function App (Consumption plan, Node.js or C# runtime — both work). Add a function called negotiate with an HTTP trigger and a SignalR input binding:
[FunctionName("negotiate")]
public static SignalRConnectionInfo Negotiate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
[SignalRConnectionInfo(HubName = "serviceorders")] SignalRConnectionInfo connectionInfo)
{
return connectionInfo;
}
The function returns a JSON object with url and accessToken fields. Your Canvas App calls this endpoint first to get credentials before establishing its SignalR connection.
Add a second function called broadcast that receives messages from Power Automate and pushes them to SignalR clients:
[FunctionName("broadcast")]
public static async Task Broadcast(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
[SignalR(HubName = "serviceorders")] IAsyncCollector<SignalRMessage> signalRMessages)
{
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
dynamic data = JsonConvert.DeserializeObject(requestBody);
await signalRMessages.AddAsync(
new SignalRMessage
{
Target = "recordUpdated",
Arguments = new[] { data }
});
}
Security note: The
negotiatefunction usesAuthorizationLevel.Anonymousin this example for simplicity. In production, validate the caller's identity here — check their Power Apps session token or use Azure AD authentication. An open negotiate endpoint lets anyone establish a connection and receive your business data events.
This is the most complex part of the setup. You need a custom connector that can:
Create a new Custom Connector in Power Apps (Data → Custom Connectors → New Custom Connector → Create from blank).
General tab:
yourapp.azurewebsites.net)/apiSecurity tab:
Definition tab — Action 1: NegotiateConnection
Summary: Negotiate SignalR Connection
Operation ID: NegotiateConnection
Request:
Method: POST
URL: /negotiate
Response:
url: string
accessToken: string
Definition tab — Action 2: ReceiveMessages
This is where it gets interesting. SignalR's REST API for serverless clients works through a long-polling mechanism at the endpoint:
GET https://[signalr-endpoint]/client/negotiate?hub=serviceorders
followed by:
GET https://[signalr-endpoint]/client/?hub=serviceorders
with the access token in the Authorization header as a Bearer token.
In the Canvas App, you'll call NegotiateConnection first, then call ReceiveMessages in a timer loop, passing the url and accessToken from the negotiation response.
The custom connector for ReceiveMessages should be defined as a GET action with dynamic URL support (use the x-ms-dynamic-url extension in the Swagger definition to pass the full URL from the negotiate response).
In App.OnStart:
// Negotiate the SignalR connection
Set(
varSignalRConnection,
SignalRConnector.NegotiateConnection()
);
// Store connection details for use in timer
UpdateContext({
locSignalRUrl: varSignalRConnection.url,
locSignalRToken: varSignalRConnection.accessToken
})
Add a Timer control to your main screen:
Duration: 5000
Repeat: true
AutoStart: !IsBlank(locSignalRToken)
OnTimerEnd:
Set(
varSignalRMessage,
SignalRConnector.ReceiveMessages(locSignalRUrl, locSignalRToken)
);
If(
!IsBlank(varSignalRMessage.recordId),
// A record was updated — refresh if we're viewing it
If(
varSignalRMessage.recordId = locCurrentRecordId,
// Show a non-disruptive notification, then refresh
Notify("This record was updated by " & varSignalRMessage.updatedBy, NotificationType.Information, 5000);
Refresh(ServiceOrders)
)
)
Practical note: The 5-second polling interval on the timer is a compromise. True WebSocket-based SignalR would give you sub-second latency. The REST long-polling approach you're using here has a 30-second timeout per request — the SignalR service holds the connection open and responds when a message arrives or the timeout expires. Setting your timer to 5 seconds just means you'll re-poll quickly if a message arrives, but for low-latency scenarios you'd want a longer timer with async handling. Canvas Apps' synchronous formula evaluation model makes true async message handling genuinely difficult.
The Power Automate flow that fires when a Dataverse record changes is straightforward:
Trigger: When a row is added, modified, or deleted (Dataverse) — select your ServiceOrders table
Actions:
broadcast endpoint:{
"recordId": "@{triggerOutputs()?['body/so_serviceorderid']}",
"tableName": "serviceorders",
"changeType": "@{triggerOutputs()?['body/_trigger_changed_columns']}",
"updatedBy": "@{triggerOutputs()?['body/_modifiedby_value@OData.Community.Display.V1.FormattedValue']}",
"updatedAt": "@{utcNow()}"
}
x-functions-key header.The broadcast function then pushes this payload to all connected SignalR clients. Each Canvas App instance receives it, checks if it's relevant to what the user is currently viewing, and refreshes selectively.
The real power comes from running all three patterns together. Here's how they interact in a realistic scenario:
A service order record has three states from the collaboration system's perspective:
The SignalR layer keeps everyone's lock status current without polling. When User A acquires a lock, the AcquireRecordLock Power Automate flow fires the SignalR broadcast after successfully writing the lock. User B's app receives the SignalR notification, refreshes, and sees the "locked by Marcus" state — all within 5 seconds.
When User A saves, the ConditionalSave flow uses the ETag (even with pessimistic locking, ETags are good practice because they verify the record didn't change after the lock was written). On successful save, the lock is released and another SignalR broadcast fires, updating all other users.
The ETag check might seem redundant with pessimistic locking, but consider: what if your Dataverse instance was down during the lock heartbeat, the lock expired, User B grabbed the lock, and now both users save within the same second when the service comes back? Defense in depth matters.
Build a collaborative Project Task Board using the patterns in this lesson. Here are your specifications:
Data model: Create a Dataverse table called ProjectTasks with fields:
pt_title (text, required)pt_description (multi-line text)pt_status (choice: Backlog, In Progress, Review, Done)pt_assignedto (lookup to SystemUser)pt_lockedbyprincipalid, pt_lockedbyname, pt_lockedattimestamp, pt_locktokenPart 1: Lock Implementation (estimated 45 minutes)
Build the AcquireRecordLock and ReleaseLock Power Automate flows using the ETag-based conditional write pattern. Test the race condition defense by opening two browser windows simultaneously and clicking "Edit" on the same task at almost the same moment. Verify that only one window acquires the lock.
Part 2: Conflict Detection (estimated 30 minutes)
Modify your save flow to use If-Match with the task's ETag. Simulate a conflict by: loading a task in Window A, directly modifying the Dataverse record through Power Apps Studio's data viewer, then trying to save from Window A. Your UI should detect the conflict and show the current vs. attempted values.
Part 3: SignalR Live Refresh (estimated 60 minutes)
Set up the Azure SignalR Service and Azure Function as described. Create the Power Automate flow on ProjectTasks that broadcasts changes. Build the custom connector and integrate the negotiate/receive pattern into your Canvas App. Verify that editing a task in one browser window causes the other window's task list to refresh within 10 seconds.
Stretch goal: Implement the "who's viewing" presence feature. When a user views (not just edits) a task, broadcast their presence via SignalR. Show a small avatar list on the task indicating "3 people are viewing this." This requires a presence table and a cleanup mechanism for stale presences.
Symptom: User navigates to a detail screen, acquires lock, navigates away (Back button), navigates back — lock token is gone, but the lock is still held in Dataverse. The lock heartbeat stops and the record stays locked until expiry.
Cause: UpdateContext variables are screen-scoped in Canvas Apps. When you navigate away and back, the screen re-initializes and locLockToken is empty.
Fix: Store lock state in global variables (Set()) or in a collection. Better yet, store the lock token in a single-row collection keyed by record ID so you can track multiple potential locks:
// When acquiring lock
Collect(
colActiveLocks,
{ recordId: locCurrentRecordId, lockToken: varLockResult.lockToken }
);
// When releasing
Remove(
colActiveLocks,
LookUp(colActiveLocks, recordId = locCurrentRecordId)
)
Symptom: The ConditionalSave flow fails with a 400 Bad Request instead of 412 on conflict.
Cause: ETags must be passed exactly as returned by Dataverse, including the surrounding quotes. If Dataverse returns W/"12345-abcdef" and you strip the W/ prefix or the quotes before sending it back, the conditional header is malformed.
Fix: Return the raw ETag string from your GetRecordWithETag flow without any string manipulation. In Power Automate, use the outputs('HTTP_Get_Record')?['headers']?['OData-ETag'] expression to capture the ETag from the response headers — not from the response body.
Symptom: Live refresh stops working after approximately 1 hour. The SignalR custom connector calls start returning 401 errors.
Cause: SignalR access tokens from the negotiate endpoint have a 1-hour expiry by default. Your app negotiated once at startup and is using the same token indefinitely.
Fix: Add token expiry tracking. The negotiate response includes an expiry time. Check it in your timer loop and re-negotiate when it's within 5 minutes of expiry:
OnTimerEnd:
If(
DateDiff(Now(), locSignalRTokenExpiry, TimeUnit.Minutes) < 5,
// Re-negotiate
Set(varSignalRConnection, SignalRConnector.NegotiateConnection());
UpdateContext({
locSignalRUrl: varSignalRConnection.url,
locSignalRToken: varSignalRConnection.accessToken,
locSignalRTokenExpiry: DateAdd(Now(), 55, TimeUnit.Minutes)
})
);
// ... rest of message receive logic
Symptom: With many concurrent users, some collaboration events stop being delivered. Power Automate flows start failing with 429 errors.
Cause: Every lock acquire, release, heartbeat, and save generates Dataverse API calls through Power Automate. At scale, this adds up fast.
Fix: Implement exponential backoff in your flows using Power Automate's retry policy (available on HTTP actions under Settings). More importantly, evaluate whether you need pessimistic locking at all — for read-heavy tables, optimistic concurrency generates far fewer API calls because you only hit the API on actual saves, not on lock acquisition and heartbeat cycles.
Also consider batching: if your SignalR broadcast triggers 50 Canvas App clients to all Refresh(ServiceOrders) simultaneously, that's 50 API calls at once. Instead of a full table refresh, use LookUp to refresh only the specific changed record using the recordId from the SignalR message.
Symptom: App becomes sluggish after running for a while, especially with multiple timer controls active.
Cause: Canvas Apps evaluate timers on the main UI thread. If your OnTimerEnd formula takes 2+ seconds (because the SignalR call is slow), the next timer tick queues up before the first one finishes, and you get overlapping executions.
Fix: Use a Boolean guard variable to prevent concurrent timer executions:
OnTimerEnd:
If(
!varTimerRunning,
Set(varTimerRunning, true);
// ... your timer logic ...
Set(varTimerRunning, false)
)
This is the Canvas Apps equivalent of a mutex. It's not thread-safe in a CS sense (Canvas Apps is single-threaded in formula evaluation), but it prevents logical re-entrancy.
At 10 users, all three patterns work fine with minimal tuning. At 100 users, you'll start feeling the pressure. Here are the scaling levers worth understanding:
SignalR connection limits: The Free tier of Azure SignalR Service allows 20 concurrent connections. The Standard tier supports 1,000 per unit, and you can add units. Plan your tier based on peak concurrent users, not total users.
Power Automate flow run costs: Each collaboration event generates flow runs. With pessimistic locking and a 60-second heartbeat, one active editor generates 1 flow run per minute just for heartbeats. Across 50 simultaneous editors, that's 50 flow runs per minute, or 72,000 per day. Premium Power Automate licenses include 40,000 runs/user/month — check your capacity before deploying broadly.
Dataverse connection reference sharing: If multiple flows use the same service account to authenticate to Dataverse, they share that account's API quota. Use separate service accounts for high-volume flows (heartbeats, locks) and lower-volume ones (saves, conflict checks) to avoid cross-contamination.
The notification fan-out problem: When a popular record changes, your Power Automate flow broadcasts one SignalR message, which causes N Canvas App clients to each call LookUp(ServiceOrders, ...) — N separate Dataverse API calls. For N=10 this is fine. For N=200 (a widely-shared dashboard showing the same records), this is a problem. Consider batching refreshes with a 1-2 second delay jitter per client (Wait(RandBetween(0, 2000)) before the refresh call) to spread the load.
You've now built a complete mental model for multi-user collaboration in Canvas Apps — an area where most Power Apps documentation either glosses over the complexity or pretends it doesn't exist.
The three patterns work together as a coherent system:
If-Match headers catches conflicts at save time, offering lighter overhead but requiring a conflict resolution UIThe architectural insight that ties them together: each pattern addresses a different layer of the time-of-check to time-of-use (TOCTOU) problem. Live refresh shrinks the window. Locking prevents simultaneous entry. Conflict detection catches what slips through.
Where to go next:
Presence awareness: Extend the SignalR pattern to broadcast "who is viewing" events, building the kind of real-time presence indicators you see in Google Docs. This requires a presence cleanup mechanism (TTL on presence records or SignalR group management).
Offline sync patterns: What happens when a user's connection drops mid-edit? Explore Canvas Apps' offline capabilities combined with conflict detection to build forms that work offline and sync gracefully.
Azure Cosmos DB as a collaboration backend: For applications where Dataverse's API limits are a genuine constraint, consider using Cosmos DB with Change Feed as your event source — it has native support for real-time change streaming and scales far beyond Dataverse's per-user limits.
Power Apps Component Framework (PCF): The real-time patterns we built here are constrained by Canvas Apps' formula engine. PCF controls let you write TypeScript that can maintain true WebSocket connections, giving you genuine sub-second collaboration latency. If you've validated the collaboration UX with Canvas Apps and need to productionize it at scale, PCF is the next frontier.
The code patterns in this lesson are starting points, not finished products. Test them under realistic concurrent load before declaring them production-ready, and build your lock expiry and conflict resolution UI with the assumption that something will always go wrong at the worst possible moment — because it will.