Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Apps

Canvas App Real-Time Collaboration Patterns: Record Locking, Conflict Detection, and Live Refresh with SignalR

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.

🔥 Expert27 min readAug 29, 2026Updated Aug 29, 2026
Canvas App Real-Time Collaboration Patterns: Record Locking, Conflict Detection, and Live Refresh with SignalR
On this page
  • Introduction
  • Prerequisites
  • Understanding Why Canvas Apps Struggle with Collaboration by Default
  • Pattern 1: Pessimistic Record Locking
  • Designing the Lock Schema
  • Acquiring the Lock in Power Fx
  • Implementing the Lock UI in Canvas App
  • The Heartbeat Mechanism
  • Releasing the Lock
  • Pattern 2: Optimistic Concurrency and Conflict Detection
  • The ETag Approach
  • The Conditional Save Flow
Building a Useful Conflict Resolution UI
  • Conflict Detection Without ETags: The Timestamp Fallback
  • Pattern 3: Live Data Refresh with SignalR
  • The Architecture Overview
  • Setting Up Azure SignalR Service
  • The SignalR Negotiate Endpoint
  • Building the Custom Connector
  • Wiring It Together in Canvas App
  • The Power Automate Side of SignalR
  • Combining the Patterns: The Full Collaboration Layer
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Lock Tokens Being Lost on Screen Navigation
  • Power Automate ETag Parsing Failures
  • SignalR Token Expiry Mid-Session
  • Dataverse API Limit Exhaustion
  • Timer Controls Causing Performance Degradation
  • Performance and Scaling Considerations
  • Summary & Next Steps
  • Canvas App Real-Time Collaboration Patterns: Implementing Record Locking, Conflict Detection, and Live Data Refresh with Power Automate and SignalR

    Introduction

    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:

    • How to implement pessimistic record locking in Canvas Apps using Dataverse status fields and Power Automate cleanup flows
    • How to detect and surface edit conflicts using row version timestamps and optimistic concurrency patterns
    • How to push real-time state changes from Power Automate into a Canvas App using Azure SignalR Service and custom connectors
    • How to design lock expiry and heartbeat mechanisms that survive browser crashes and network drops
    • How to architect the refresh and notification layer so it scales without hammering your Dataverse API limits

    Prerequisites

    This lesson assumes you're comfortable with intermediate-to-advanced Canvas App development. Specifically, you should already know:

    • Writing complex Power Fx formulas including Patch, Filter, LookUp, and UpdateContext
    • Building Power Automate flows that read and write Dataverse records
    • Creating custom connectors in Power Apps (or be willing to follow the configuration steps closely)
    • Basic familiarity with REST APIs and HTTP request/response structure
    • Azure portal navigation at a beginner level — you don't need to be an Azure expert, but you need to create resources without hand-holding

    You'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).


    Understanding Why Canvas Apps Struggle with Collaboration by Default

    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:

    1. Pessimistic locking prevents concurrent edits from ever starting — only one user can hold the edit lock at a time.
    2. Optimistic concurrency allows concurrent edits but detects collisions before the final save and asks the user what to do.
    3. Live refresh ensures all users see current data, so the collision surface area shrinks dramatically.

    These patterns complement each other. You'll rarely implement just one.


    Pattern 1: Pessimistic Record Locking

    Designing the Lock Schema

    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.

    Acquiring the Lock in Power Fx

    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

    • Trigger: HTTP Request (from Canvas App via custom connector)
    • Input: recordId (GUID), requestorObjectId (AAD Object ID), requestorDisplayName (string)

    The flow body:

    1. Get the record from Dataverse using the recordId
    2. Check if it's locked: Is so_lockedbyprincipalid empty, OR is the current time more than 15 minutes past so_lockedattimestamp?
    3. If not locked (or expired): Update the record with the new lock fields, generate a fresh lock token, return { "acquired": true, "lockToken": "..." }
    4. If locked by someone else and not expired: Return { "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.

    Implementing the Lock UI in Canvas App

    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.

    The Heartbeat Mechanism

    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: true and the timer fires before the screen is navigated away from. Always set AutoStart to a condition, not a constant true, and reset it when leaving the edit screen.

    Releasing the Lock

    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.


    Pattern 2: Optimistic Concurrency and Conflict Detection

    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 ETag Approach

    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
    })
    

    The Conditional Save Flow

    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.

    Building a Useful Conflict Resolution UI

    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:

    1. Fetch the current version of the record from Dataverse
    2. Return it alongside the user's attempted changes

    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:

    • Keep Mine — force-save with If-Match: * (match any ETag, bypass concurrency check)
    • Take Theirs — discard local changes, reload the record
    • Merge Manually — allow the user to edit a combined version before saving

    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.

    Conflict Detection Without ETags: The Timestamp Fallback

    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.


    Pattern 3: Live Data Refresh with SignalR

    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.

    The Architecture Overview

    Here's how the pieces fit together:

    1. A Power Automate flow triggers when a Dataverse record is created or modified
    2. The flow calls an Azure Function (or API Management policy) that acts as a SignalR hub
    3. The Azure SignalR Service broadcasts a message to all connected Canvas App clients
    4. The Canvas App receives the message via a custom connector and triggers a targeted refresh

    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.

    Setting Up Azure SignalR Service

    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:

    • The Connection String (used by Power Automate and Azure Functions)
    • The Endpoint URL (used by your custom connector)

    The SignalR Negotiate Endpoint

    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 negotiate function uses AuthorizationLevel.Anonymous in 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.

    Building the Custom Connector

    This is the most complex part of the setup. You need a custom connector that can:

    1. Call the negotiate endpoint to get connection credentials
    2. Maintain a polling connection to the SignalR REST API to receive messages

    Create a new Custom Connector in Power Apps (Data → Custom Connectors → New Custom Connector → Create from blank).

    General tab:

    • Host: your Azure Function App hostname (e.g., yourapp.azurewebsites.net)
    • Base URL: /api

    Security tab:

    • Authentication: No authentication (you'll handle tokens per-request in the definition)

    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).

    Wiring It Together in Canvas App

    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 Side of SignalR

    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:

    1. HTTP action — POST to your Azure Function 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()}"
    }
    
    1. Include the Azure Function key in the 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.


    Combining the Patterns: The Full Collaboration Layer

    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:

    • Unlocked, current — normal viewing state
    • Locked by current user — edit mode with heartbeat running
    • Locked by other user — read-only with "Marcus is editing" banner

    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.


    Hands-On Exercise

    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)
    • Lock fields: pt_lockedbyprincipalid, pt_lockedbyname, pt_lockedattimestamp, pt_locktoken

    Part 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.


    Common Mistakes & Troubleshooting

    Lock Tokens Being Lost on Screen Navigation

    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)
    )
    

    Power Automate ETag Parsing Failures

    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.

    SignalR Token Expiry Mid-Session

    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
    

    Dataverse API Limit Exhaustion

    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.

    Timer Controls Causing Performance Degradation

    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.


    Performance and Scaling Considerations

    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.


    Summary & Next Steps

    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:

    • Pessimistic locking with ETag-protected conditional writes prevents conflicts before they start, at the cost of lock management overhead
    • Optimistic concurrency with If-Match headers catches conflicts at save time, offering lighter overhead but requiring a conflict resolution UI
    • SignalR-based live refresh keeps all users current without polling, eliminating the stale-data window where conflicts even become possible

    The 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.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Canvas Apps 101

    Previous

    Canvas App Bulk Data Operations: Multi-Record Patch, ForAll Processing, and Progress Tracking for Large Dataset Updates

    Related Insights

    Power AppsPractitioner

    Canvas App Bulk Data Operations: Multi-Record Patch, ForAll Processing, and Progress Tracking for Large Dataset Updates

    19 min
    Power AppsFoundation

    Understanding Power Apps Screens, Containers, and Layout Controls: Building Your First Structured App Interface

    16 min
    Power AppsExpert

    Canvas App Accessibility Compliance: WCAG Standards, Screen Reader Support, and Keyboard Navigation for Enterprise Power Apps

    29 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding Why Canvas Apps Struggle with Collaboration by Default
    • Pattern 1: Pessimistic Record Locking
    • Designing the Lock Schema
    • Acquiring the Lock in Power Fx
    • Implementing the Lock UI in Canvas App
    • The Heartbeat Mechanism
    • Releasing the Lock
    • Pattern 2: Optimistic Concurrency and Conflict Detection
    • The ETag Approach
    • The Conditional Save Flow
    • Building a Useful Conflict Resolution UI
    • Conflict Detection Without ETags: The Timestamp Fallback
    • Pattern 3: Live Data Refresh with SignalR
    • The Architecture Overview
    • Setting Up Azure SignalR Service
    • The SignalR Negotiate Endpoint
    • Building the Custom Connector
    • Wiring It Together in Canvas App
    • The Power Automate Side of SignalR
    • Combining the Patterns: The Full Collaboration Layer
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Lock Tokens Being Lost on Screen Navigation
    • Power Automate ETag Parsing Failures
    • SignalR Token Expiry Mid-Session
    • Dataverse API Limit Exhaustion
    • Timer Controls Causing Performance Degradation
    • Performance and Scaling Considerations
    • Summary & Next Steps