PowerApps Performance Optimization: Delegation, Collections, and Best Practices

PowerApps Performance Optimization: Delegation, Collections, and Best Practices

Introduction

PowerApps performance directly impacts user adoption. Slow apps frustrate users and reduce productivity. This guide covers delegation to handle large datasets, collection optimization, caching strategies, concurrent operations, component performance, and monitoring tools to build responsive canvas apps that scale.

Understanding Delegation

What is Delegation?

Delegation means PowerApps sends query logic to the data source (SharePoint, Dataverse, SQL) rather than downloading all records to the device. Non-delegable queries download the first 500 (or 2000 max) records, then filter/sort locallyβ€”missing data beyond that limit.

Delegable operations:

Data Source Delegable Functions
Dataverse Filter, Search, Sort, LookUp, Sum, Average, Min, Max, CountRows
SharePoint Filter (limited), Search, Sort, LookUp
SQL Server Filter, Search, Sort, LookUp, Sum, Average, Min, Max, CountRows
Excel None (all non-delegable)

Delegation warnings (yellow triangles):

// ⚠️ Non-delegable - Downloads first 2000, filters locally
Filter(LargeSharePointList, Status = "Active" && DateCreated > Today() - 30)

// βœ… Delegable - Filters on server
Filter(DataverseTable, Status = "Active" And DateCreated > Today() - 30)

Delegation Limits

App settings:

File β†’ Settings β†’ Advanced settings
└── Data row limit for non-delegable queries: 500 (default) to 2000 (max)

Warning: Increasing limit doesn't solve delegation issuesβ€”downloads more data, making app slower.

Fixing Delegation Issues

Example: Filter by text contains (non-delegable in SharePoint):

// ❌ Non-delegable - only checks first 500/2000 records
Filter(SharePointList, "keyword" in Title)

// βœ… Solution 1: Use Search (delegable)
Search(SharePointList, "keyword", "Title")

// βœ… Solution 2: Add indexed column and exact match
Filter(SharePointList, Status = "Active")  // Status is indexed

Multiple conditions (AND/OR):

// ❌ Non-delegable - complex formula
Filter(Items, Status = "Active" && (Priority = "High" || Priority = "Critical"))

// βœ… Delegable - simplified
Filter(Items, Status = "Active" And Priority = "High") 
// Run second query for Critical, then combine with Concat or Union

Calculated columns (always non-delegable):

// ❌ Non-delegable - Year() function
Filter(Orders, Year(OrderDate) = 2025)

// βœ… Solution: Add date range filter
Filter(Orders, OrderDate >= Date(2025,1,1) And OrderDate <= Date(2025,12,31))

Collection Optimization

When to Use Collections

Good use cases:

  • Cache static data (products, categories, configuration)
  • Temporary storage for multi-step forms
  • Performance optimization for repeated queries
  • Offline data storage

Bad use cases:

  • Large datasets (>5000 records) - Use delegation instead
  • Frequently changing data - Query directly
  • Complex filtering - Let data source handle it

Efficient Collection Loading

Load on app start (OnStart):

// ❌ Slow - Three sequential calls (6+ seconds)
ClearCollect(colProducts, Products);
ClearCollect(colCategories, Categories);
ClearCollect(colUsers, Users)

// βœ… Fast - Concurrent calls (2 seconds)
Concurrent(
    ClearCollect(colProducts, Products),
    ClearCollect(colCategories, Categories),
    ClearCollect(colUsers, Users)
)

// βœ… Better - Load only what's needed
Concurrent(
    ClearCollect(colProducts, 
        Filter(Products, Status = "Active")
    ),
    ClearCollect(colCategories, Categories),
    Set(varCurrentUser, User())
)

Collection vs. Variables

Collection (table):

// Multi-row data
ClearCollect(colSelectedItems,
    {ID: 1, Name: "Item A"},
    {ID: 2, Name: "Item B"}
)

// Use in gallery
Gallery1.Items = colSelectedItems

Variable (single value or record):

// Single value
Set(varTotalAmount, 1500)

// Single record
Set(varCurrentItem, LookUp(Items, ID = 123))

// Use in label
Label1.Text = varCurrentItem.Title

Global vs. Context variables:

// Global variable (accessible everywhere)
Set(gblUserRole, "Manager")

// Context variable (screen-specific)
UpdateContext({locScreenLoading: true})

// Best practice: Use context variables for screen state
UpdateContext({
    locSelectedCategory: Blank(),
    locFilterText: "",
    locShowDetails: false
})

Caching Strategies

Static Data Caching

Cache lookup data on start:

// App.OnStart
Concurrent(
    ClearCollect(colStatusChoices,
        Choices('Cases'.'Status')
    ),
    ClearCollect(colPriorityChoices,
        Choices('Cases'.'Priority')
    ),
    ClearCollect(colDepartments,
        Departments  // Small reference table
    )
);
Set(varAppReady, true)

// Use cached data in dropdown
Dropdown_Status.Items = colStatusChoices

Lazy Loading Pattern

Load data only when screen is accessed:

// Screen1.OnVisible
If(IsEmpty(colProducts),
    ClearCollect(colProducts,
        Filter(Products, Status = "Active")
    )
)

// Check if cache is stale (refresh after 5 minutes)
If(DateDiff(varLastProductsLoad, Now(), Minutes) > 5,
    ClearCollect(colProducts, Products);
    Set(varLastProductsLoad, Now())
)

Refresh Strategy

Manual refresh button:

// RefreshButton.OnSelect
UpdateContext({locRefreshing: true});
Concurrent(
    ClearCollect(colProducts, Products),
    ClearCollect(colOrders, 
        Filter(Orders, Status = "Pending")
    )
);
UpdateContext({locRefreshing: false})

Auto-refresh with timer:

// Timer control
Duration: 300000  // 5 minutes in milliseconds
AutoStart: true
Repeat: true
OnTimerEnd: 
    Refresh(Products);
    Refresh(Orders)

Gallery Performance

Pagination Pattern

Load 50 items at a time:

// Global variables on screen load
Set(varPageSize, 50);
Set(varCurrentPage, 1);
UpdateContext({locTotalPages: RoundUp(CountRows(Products) / varPageSize, 0)})

// Gallery items
Gallery_Products.Items = 
    FirstN(
        Filter(Products, Status = "Active"),
        varPageSize * varCurrentPage
    )

// Next button
Button_Next.OnSelect =
    If(varCurrentPage < locTotalPages,
        Set(varCurrentPage, varCurrentPage + 1)
    )

// Previous button  
Button_Prev.OnSelect =
    If(varCurrentPage > 1,
        Set(varCurrentPage, varCurrentPage - 1)
    )

// Page counter label
Label_PageInfo.Text = 
    "Page " & varCurrentPage & " of " & locTotalPages

Gallery Item Optimization

Minimize controls per item:

// ❌ Slow - 10 controls per item Γ— 100 items = 1000 controls
Gallery with:
β”œβ”€β”€ 3 Labels
β”œβ”€β”€ 2 Icons
β”œβ”€β”€ 3 Buttons
β”œβ”€β”€ 1 Image
└── 1 Checkbox

// βœ… Fast - 4 controls per item Γ— 100 items = 400 controls
Gallery with:
β”œβ”€β”€ 1 HTML text (combines 3 labels)
β”œβ”€β”€ 1 Icon (conditional color)
β”œβ”€β”€ 1 Button with icon
└── 1 Image

HTML text for multiple labels:

// Instead of 3 separate labels:
"<div style='padding:10px'>
    <h3>" & ThisItem.Title & "</h3>
    <p>" & ThisItem.Description & "</p>
    <small>Created: " & Text(ThisItem.Created, "mm/dd/yyyy") & "</small>
</div>"

Visible Property Optimization

Use gallery visibility, not item visibility:

// ❌ Slow - Evaluates for every item
Gallery_Products.Items = Products
Item1_Container.Visible = ThisItem.Status = "Active"

// βœ… Fast - Filter data source
Gallery_Products.Items = Filter(Products, Status = "Active")
Item1_Container.Visible = true

Formula Optimization

Avoid Nested Lookups

// ❌ Slow - Lookup for every gallery item
Gallery_Orders.Items = Orders
Label_CustomerName.Text = 
    LookUp(Customers, ID = ThisItem.CustomerID).Name

// βœ… Fast - Expand relationship in data source
Gallery_Orders.Items = Orders
Label_CustomerName.Text = ThisItem.Customer.Name

// βœ… Alternative - Join collections on app start
ClearCollect(colOrdersWithCustomers,
    AddColumns(Orders, 
        "CustomerName", 
        LookUp(Customers, ID = Orders[@CustomerID]).Name
    )
)

With() Function for Repeated Expressions

// ❌ Slow - Calculates LookUp 3 times
If(
    LookUp(Products, ID = varSelectedID).Price > 1000,
    Set(varDiscount, LookUp(Products, ID = varSelectedID).Price * 0.1),
    Set(varDiscount, LookUp(Products, ID = varSelectedID).Price * 0.05)
)

// βœ… Fast - Calculates LookUp once
With(
    {selectedProduct: LookUp(Products, ID = varSelectedID)},
    If(
        selectedProduct.Price > 1000,
        Set(varDiscount, selectedProduct.Price * 0.1),
        Set(varDiscount, selectedProduct.Price * 0.05)
    )
)

ForAll for Bulk Operations

// ❌ Slow - Loop executes sequentially
ForAll(colSelectedItems,
    Patch(Orders, Defaults(Orders), {
        ProductID: ID,
        Quantity: 1,
        OrderDate: Today()
    })
)

// βœ… Fast - Single Patch call
Patch(Orders,
    ForAll(colSelectedItems,
        {
            ProductID: ID,
            Quantity: 1,
            OrderDate: Today()
        }
    )
)

Component Best Practices

Input Properties vs. Internal State

// Component: ProductCard
// ❌ Slow - Re-renders on every parent change
ProductCard.ProductID = Gallery1.Selected.ID
// Inside component: product data fetched on every change

// βœ… Fast - Pass complete record
ProductCard.Product = Gallery1.Selected
// Inside component: use Product.ID directly

Component Output Properties

// Inside ProductCard component
// Define custom output property
OnSelect = Set(locSelectedProduct, Product); UpdateContext({locShowDetails: true})

// Component output property definition
Name: SelectedProduct
Type: Record
Formula: locSelectedProduct

// Parent screen uses output
If(!IsBlank(ProductCard1.SelectedProduct),
    Navigate(DetailScreen, ScreenTransition.Fade, {
        itemID: ProductCard1.SelectedProduct.ID
    })
)

Reusable Form Components

// Component: DynamicForm
// Input properties:
β”œβ”€β”€ FormSchema (Table of field definitions)
β”œβ”€β”€ FormData (Record)
└── OnSubmitAction (Behavior)

// Usage in parent screen
DynamicForm1.FormSchema = [
    {FieldName: "Title", Type: "Text", Required: true},
    {FieldName: "Priority", Type: "Dropdown", Choices: colPriorityChoices},
    {FieldName: "DueDate", Type: "Date", Required: true}
]

DynamicForm1.FormData = Gallery1.Selected
DynamicForm1.OnSubmitAction = 
    Patch(Cases, Gallery1.Selected, DynamicForm1.FormValues)

Monitoring and Debugging

Monitor Tool

Enable Monitor:

Advanced Tools β†’ Monitor

Key metrics to track:

  • Duration: Formula execution time (target <100ms)
  • Data calls: Number of API requests (minimize)
  • Delegation warnings: Yellow triangles indicate issues
  • Error messages: Red indicators show failures

Filter for slow operations:

Monitor β†’ Filter by duration > 500ms

App Insights Integration

Enable telemetry:

// App.OnStart
Set(varSessionID, GUID());
Set(varAppVersion, "1.2.0")

// Track custom events
Trace(
    "Order Created",
    TraceSeverity.Information,
    {
        OrderID: varNewOrderID,
        CustomerID: varCustomerID,
        Amount: varOrderTotal,
        SessionID: varSessionID
    }
)

// Track errors
IfError(
    Patch(Orders, Defaults(Orders), formData),
    Trace(
        "Order Creation Failed",
        TraceSeverity.Error,
        {
            ErrorMessage: FirstError.Message,
            SessionID: varSessionID
        }
    )
)

Performance Checklist

βœ… Data operations:

  • Use delegation wherever possible
  • Cache static data in collections
  • Load data concurrently with Concurrent()
  • Implement pagination for large datasets

βœ… Formulas:

  • Avoid nested LookUps in galleries
  • Use With() for repeated expressions
  • Use ForAll for bulk operations
  • Minimize calculations in gallery items

βœ… Controls:

  • Limit controls per gallery item (<5)
  • Use HTML text instead of multiple labels
  • Hide screens with Visible, not complex logic
  • Optimize component input properties

βœ… Images:

  • Use SVG for icons (smaller, scalable)
  • Compress images (<100KB each)
  • Load images lazily (when needed)
  • Cache images in collections if reused

Best Practices

  1. Test with Large Data: Always test with 10,000+ records
  2. Use Monitor Tool: Profile every screen before release
  3. Implement Caching: Load reference data once on start
  4. Lazy Load Screens: Only load data when screen is visible
  5. Minimize API Calls: Batch operations, use concurrent calls
  6. Optimize Galleries: Pagination, reduce controls per item
  7. Document Delegation: Comment non-delegable workarounds

Troubleshooting

App loads slowly:

  • Check App.OnStart for sequential operations (use Concurrent)
  • Remove unnecessary data loading
  • Defer heavy operations to screen load

Gallery scrolling is laggy:

  • Reduce controls per gallery item
  • Remove complex formulas in item templates
  • Implement pagination instead of loading all items

Forms submit slowly:

  • Batch Patch operations
  • Remove duplicate data calls
  • Check for nested LookUps

Key Takeaways

  • Delegation is critical for apps with >500 records
  • Collections cache data but shouldn't replace delegation
  • Concurrent() reduces app start time by 60-80%
  • Gallery optimization (pagination, fewer controls) improves UX
  • Monitor tool reveals performance bottlenecks
  • Test with production-scale data (10,000+ records)

Next Steps

  • Implement connection pooling with Dataverse
  • Use virtual galleries for massive datasets (preview feature)
  • Enable App Insights for production monitoring
  • Explore Power Fx experimental features (query functions)

Additional Resources


Fast apps, happy users.