
You've built a Power Apps canvas app that works beautifully in your development environment with 50 test records. Then you deploy it to production, connect it to a SharePoint list with 12,000 rows, and suddenly your gallery shows the wrong search results, your filter returns incomplete data, and users are complaining the app takes 8 seconds to load. Sound familiar? This is the delegation trap — and it catches almost every Power Apps developer at least once.
Performance in Power Apps isn't just about user experience. When an app processes data incorrectly because of delegation limitations, you're not just slow — you're wrong. A search that silently misses 11,500 records is worse than a search that times out, because at least a timeout tells the user something failed. The delegation problem fails silently, returning a confident-looking but deeply incomplete result set. This lesson is about understanding why this happens at a systems level, and then building the habits and formulas that prevent it.
By the end of this lesson, you'll be able to build Power Apps that handle real production data volumes correctly and efficiently. You'll know how to diagnose delegation warnings before they become production bugs, how to architect your data queries to stay within Power Apps' processing model, and how to slash app load times with concrete techniques like named formulas, explicit column selection, and strategic use of collections.
What you'll learn:
App.StartScreen, Concurrent(), and on-demand data loadingYou should already be comfortable building basic canvas apps — creating screens, binding galleries to data sources, writing Filter() and LookUp() formulas, and connecting to SharePoint or Dataverse. You don't need to be an expert, but if terms like "data source," "gallery," and "formula bar" are unfamiliar, start with the Canvas Apps 101 fundamentals lesson first.
Before you can fix delegation problems, you need to understand why they exist. Power Apps can connect to dozens of different data sources — SharePoint, Dataverse, SQL Server, Salesforce, Excel files, and more. Each of these has a completely different query engine underneath. SQL Server speaks T-SQL. SharePoint speaks CAML queries. Dataverse has its own OData endpoint.
When you write a Power Apps formula like:
Filter(EmployeeDirectory, Department = "Engineering")
Power Apps has two choices for how to execute this:
Option 1 is delegation. The filter work is delegated to the data source. Option 2 is local execution — and it's where the row limit problem lives.
The problem with option 2 isn't just performance. It's correctness. Power Apps will only download a maximum of 500 rows by default (configurable to 2000) before applying your local filter. If your SharePoint list has 12,000 employees in the Engineering department, you'll get a filtered result drawn from only the first 2000 records. The app will show you results — it just won't show you all the results. There's no error. There's no warning to the user. Just silent incompleteness.
This is the delegation trap: Non-delegable formulas don't fail loudly. They return confidently wrong results.
Power Apps does show you delegation warnings in the formula bar (yellow triangles with exclamation marks), but only if you know to look for them — and many developers learn to ignore them without understanding their consequences.
In Power Apps Studio, when you write a non-delegable formula, you'll see a yellow warning triangle on the formula. Hovering over it shows something like: "The formula might not work correctly with large datasets. This data source may not support this operation."
There's also a dedicated way to see all delegation warnings across your app: go to File > App checker, which lists every formula with a delegation issue and the screen it appears on. Make it a habit to check this before every production deployment.
Delegation capability depends on both the function AND the data source. The same formula can delegate to Dataverse but not to SharePoint. Here's a practical reference for common scenarios:
SharePoint delegation support:
// DELEGABLE — these translate to server-side CAML queries
Filter(Projects, Status = "Active")
Filter(Projects, DueDate <= Today())
Filter(Projects, Title = SearchBox.Text) // exact match
Search(Projects, SearchBox.Text, "Title") // SharePoint supports this
// NOT DELEGABLE — processed locally, subject to 2000 row cap
Filter(Projects, StartsWith(Title, SearchBox.Text)) // StartsWith delegates for some sources, not all
Filter(Projects, Len(Title) > 20) // Len() doesn't delegate
Filter(Projects, Category in ["IT", "Finance", "Legal"]) // 'in' operator often doesn't delegate
SortByColumns(Filter(Projects, Status = "Active"), "CreatedDate", Descending) // complex combos
Dataverse delegation support (generally much broader):
// DELEGABLE on Dataverse — Dataverse has the richest delegation support
Filter(Accounts, StartsWith(name, SearchBox.Text))
Filter(Orders, totalamount > 50000 && statuscode = 1)
CountRows(Filter(Orders, customerid = currentCustomer.id))
// NOT DELEGABLE even on Dataverse
Filter(Accounts, Text(createdon, "yyyy") = "2023") // Text() doesn't delegate
Filter(Orders, Mid(ordernumber, 3, 2) = "PO") // string manipulation doesn't delegate
Pro tip: When in doubt, test with a data source that has more than 2000 rows. A formula that appears to work with test data may silently fail in production.
Go to Settings > General inside Power Apps Studio, and you'll find "Data row limit for non-delegable queries." The default is 500. You can increase it to a maximum of 2000.
Many developers find this setting and think: "Great, I'll just set it to 2000 and my problems go away." They don't. Here's the reality:
The 2000 row limit does have a legitimate use case: when you know your dataset will never exceed that size and you intentionally want to load it locally (think reference tables, lookup lists, configuration data). For those scenarios, explicitly loading the data into a collection at app start is actually better practice, which we'll cover in the collections section.
Let's work through the most common delegation problems and their solutions using a realistic scenario: a project management app connected to a SharePoint list called ProjectTracker with columns: Title (text), Status (choice), Owner (person), Budget (number), DueDate (date), Category (choice), Description (multi-line text).
A search box that filters the project list is extremely common. Here's the problematic version:
// NON-DELEGABLE on SharePoint — triggers row limit warning
Filter(
ProjectTracker,
SearchBox.Text in Title || SearchBox.Text in Description
)
The in operator for text search doesn't delegate to SharePoint. The delegable fix:
// DELEGABLE on SharePoint — uses Search() function
Search(
ProjectTracker,
SearchBox.Text,
"Title"
)
Search() does delegate to SharePoint, but only against text columns and only one column at a time (or a small set). If you need to search across multiple columns, chain them differently or reconsider your data architecture.
What if you need to search both Title AND a description field? Here's an honest answer: SharePoint's delegation doesn't support multi-column full-text search natively. Your options are:
// NON-DELEGABLE — multiple conditions with a lookup
Filter(
ProjectTracker,
Status.Value = "Active" && Year(DueDate) = 2024
)
The Year() function doesn't delegate. Rewrite it using date comparison:
// DELEGABLE — uses date comparison instead of Year()
Filter(
ProjectTracker,
Status.Value = "Active" &&
DueDate >= Date(2024, 1, 1) &&
DueDate < Date(2025, 1, 1)
)
This is the general pattern: replace function calls on columns with direct comparisons. Instead of Len(Title) > 20, filter on a column that already contains a pre-computed flag. Instead of Left(ProjectCode, 2) = "IT", add a Department column to SharePoint and filter on that.
// NON-DELEGABLE — User() properties don't delegate cleanly on SharePoint
Filter(ProjectTracker, Owner.Email = User().Email)
This one is tricky. On SharePoint, filtering by the current user works for person columns using a special approach:
// DELEGABLE on SharePoint — filtering by current user
Filter(ProjectTracker, Owner.Email = User().Email)
Actually, whether this delegates depends on the SharePoint connector version and list configuration. The most reliable approach is:
// Store current user email once, use it in the filter
// In App.OnStart or App.Formulas:
Set(gblCurrentUserEmail, User().Email);
// Then in your gallery:
Filter(ProjectTracker, Owner.Email = gblCurrentUserEmail)
This doesn't necessarily change delegation, but it's a best practice — calling User() inside a deeply nested formula can cause unexpected behavior, and isolating it to a variable makes it debuggable.
// This combination often causes delegation issues on SharePoint
SortByColumns(
Filter(ProjectTracker, Status.Value = "Active"),
"DueDate",
Ascending
)
On SharePoint, sorting a filtered result can break delegation. The solution: apply SortByColumns on a column that SharePoint can index, and ensure the Filter is delegable on its own first. SharePoint lists can have up to 20 indexed columns — make sure DueDate and Status are indexed in the SharePoint list settings for best results.
Delegation is about correctness. Load time is about perceived performance. Both matter. A correct app that takes 12 seconds to load will still get abandoned.
Most Power Apps tutorials teach you to load all your data in App.OnStart:
// SLOW — everything blocks in sequence
App.OnStart:
Set(gblCurrentUser, LookUp(UserProfiles, Email = User().Email));
ClearCollect(colProjects, ProjectTracker);
ClearCollect(colClients, ClientList);
ClearCollect(colStatusOptions, Choices(ProjectTracker.Status));
Navigate(HomeScreen)
The problem: every line in App.OnStart runs sequentially. The app sits on the loading screen until every single line completes. If ProjectTracker has 800 rows and ClientList has 300, you're fetching 1100 rows before the user sees anything.
Concurrent() lets you run multiple operations simultaneously rather than sequentially:
// FASTER — data sources load in parallel
App.OnStart:
Concurrent(
Set(gblCurrentUser, LookUp(UserProfiles, Email = User().Email)),
ClearCollect(colClients, ClientList),
ClearCollect(colStatusOptions, Choices(ProjectTracker.Status))
);
Navigate(HomeScreen)
Notice that colProjects is no longer loaded in App.OnStart at all. We'll lazy-load that on the screen that needs it. The three remaining operations now run in parallel, and Navigate happens only after they complete.
Warning: Don't put operations inside
Concurrent()that depend on each other. If you needgblCurrentUserto load before filtering projects, don't put both in the sameConcurrent()block.
Power Apps introduced App.Formulas (named formulas) as a more powerful alternative to setting global variables in App.OnStart. Named formulas are evaluated lazily — they only compute when the value is actually needed, and they automatically recalculate when their dependencies change.
// In App.Formulas (not App.OnStart)
CurrentUser = LookUp(UserProfiles, Email = User().Email);
ActiveProjects = Filter(ProjectTracker, Status.Value = "Active" && Owner.Email = User().Email);
StatusChoices = Choices([@ProjectTracker].Status);
With named formulas, ActiveProjects is only fetched when a screen actually references it. If a user navigates directly to the Settings screen without ever visiting the Projects screen, that data never loads. This is lazy evaluation, and it's a major win for apps with multiple screens where users only visit a subset.
Compatibility note: Named formulas (App.Formulas) require the app to have the "Improved performance for hidden controls" experimental feature enabled in some older environments, but are now generally available. Check your environment's Power Platform admin center for the current status.
By default, ClearCollect(colProjects, ProjectTracker) loads every column in your SharePoint list — including columns you never display in the app. SharePoint lists often have 30, 40, even 60 columns. You're fetching all of them.
Use ShowColumns() to explicitly limit what gets loaded:
// SLOW — loads all 45 columns
ClearCollect(colProjects, ProjectTracker)
// FAST — loads only the 6 columns the app actually uses
ClearCollect(
colProjects,
ShowColumns(
ProjectTracker,
"Title",
"Status",
"Owner",
"DueDate",
"Budget",
"ID"
)
)
This can reduce payload size dramatically — sometimes by 70-80% — which directly translates to faster load times and less memory usage.
Important: Always include the
IDcolumn, even if you don't display it. You'll need it for updates, deletes, and lookups.
Instead of loading everything in App.OnStart, load data in the OnVisible property of the screen that needs it:
// ProjectsScreen.OnVisible
If(
IsEmpty(colProjects),
ClearCollect(
colProjects,
ShowColumns(
Filter(ProjectTracker, Status.Value <> "Archived"),
"Title", "Status", "Owner", "DueDate", "Budget", "ID"
)
)
)
The IsEmpty(colProjects) check means the data only loads once — on the first visit to the screen. Subsequent visits use the cached collection. This pattern gives you lazy loading with smart caching.
// Old pattern — navigate manually after OnStart completes
App.OnStart: [your logic]; Navigate(HomeScreen)
// Better — set the start screen directly
App.StartScreen = HomeScreen
App.StartScreen tells Power Apps which screen to show first without requiring Navigate(). This eliminates one round-trip and means the app can start rendering the first screen while other background operations are still completing.
Collections are in-memory tables that live inside the app. They're not connected to a data source — they're a local copy of data. Used strategically, they're a major performance tool. Used carelessly, they bloat memory and cause unexpected behavior.
Here's a complete data loading strategy for a project management app:
// App.Formulas — lazy-loaded, auto-refreshing
CurrentUser = LookUp(UserProfiles, Email = User().Email);
// App.OnStart — load small reference data that every screen needs
Concurrent(
// Status choices — tiny, fast, needed everywhere
ClearCollect(colStatusChoices, Choices([@ProjectTracker].Status)),
// Department list — small reference table
ClearCollect(colDepartments, ShowColumns(DepartmentList, "Title", "ID", "HeadCount")),
// Current user's direct reports — small, personalized
ClearCollect(
colMyTeam,
ShowColumns(
Filter(UserProfiles, ManagerEmail = User().Email),
"DisplayName", "Email", "Department", "ID"
)
)
);
// ProjectsScreen.OnVisible — load only when needed
If(
IsEmpty(colProjects),
ClearCollect(
colProjects,
ShowColumns(
Filter(
ProjectTracker,
Status.Value <> "Archived" &&
DueDate >= DateAdd(Today(), -90, Days)
),
"Title", "Status", "Owner", "DueDate", "Budget", "Category", "ID"
)
)
)
Once data is in a collection, every Power Apps function works on it without delegation concerns — because it's local data, not a server query:
// This gallery formula uses colProjects (a collection)
// ALL of these work correctly regardless of row count in the collection
Filter(
colProjects,
(IsBlank(SearchBox.Text) || SearchBox.Text in Title) &&
(StatusDropdown.Selected.Value = "All" || Status.Value = StatusDropdown.Selected.Value) &&
(IsBlank(DatePicker.SelectedDate) || DueDate <= DatePicker.SelectedDate)
)
The trade-off: this works perfectly for the data that's in the collection, but the collection itself was filtered on load to a reasonable subset. You're trading completeness for performance. For most business applications — where "active projects from the last 90 days" is a reasonable working set — this is exactly the right trade-off.
Power Apps Monitor is your most powerful diagnostic tool. Access it from Advanced Tools > Monitor in the left navigation of Power Apps Studio, then play the app. Monitor shows you:
Look for:
ShowColumns() optimizationIn newer versions of Power Apps Studio, go to Settings > Upcoming features > Experimental and enable Power Apps Ideas or look for App Diagnostics. The profiler shows a flamegraph-style breakdown of where time is spent during app startup.
If a gallery with 50 items is making your formula run 50 times when the filter changes, that's a symptom of putting expensive operations inside gallery item formulas. The rule: never call a data source directly from inside a gallery item formula. Always filter a collection or use a pre-fetched variable.
// BAD — this LookUp runs once for EVERY row in the gallery
Gallery1.Items = ProjectTracker
// And in the gallery template label:
Label.Text = LookUp(ClientList, ID = ThisItem.ClientID, CompanyName)
// GOOD — join the data before the gallery sees it
// In Screen.OnVisible:
ClearCollect(
colProjectsWithClients,
AddColumns(
Filter(ProjectTracker, Status.Value = "Active"),
"ClientName",
LookUp(colClients, ID = ThisItem.ClientID, CompanyName)
)
)
// Gallery uses the pre-joined collection:
Gallery1.Items = colProjectsWithClients
The first pattern makes one LookUp call per gallery row — 50 rows means 50 network calls. The second pattern makes one AddColumns pass at load time.
You're building a Project Dashboard app connected to a SharePoint list called ProjectTracker (assume it has 5,000 rows) and a small ClientDirectory list (200 rows). The dashboard has three screens: Home, Projects, and Reports.
Your task: Apply all the optimization patterns from this lesson. Here's the starting (unoptimized) app configuration to fix:
Current App.OnStart (broken — fix this):
ClearCollect(colProjects, ProjectTracker);
ClearCollect(colClients, ClientDirectory);
Set(gblUser, LookUp(UserProfiles, Email = User().Email));
Navigate(HomeScreen)
Current Projects Gallery formula (broken — fix this):
Filter(
ProjectTracker,
SearchBox.Text in Title,
Status.Value = StatusFilter.Selected.Value
)
Step 1: Rewrite App.OnStart to use Concurrent(), eliminate the full ProjectTracker load, and load only reference data.
Step 2: Add App.Formulas with a named formula for CurrentUser.
Step 3: Add ProjectsScreen.OnVisible with a properly delegable, column-limited load of projects.
Step 4: Fix the gallery formula to operate on colProjects instead of directly on ProjectTracker, and rewrite the search to be correct (not just delegable — it's now a local collection, so delegation doesn't apply, but correctness still matters).
Step 5: Add AddColumns to pre-join client names into colProjects so the gallery doesn't need to call LookUp per row.
Sample solution for App.OnStart:
Concurrent(
ClearCollect(
colClients,
ShowColumns(ClientDirectory, "ID", "CompanyName", "ContactEmail", "Industry")
),
ClearCollect(colStatusChoices, Choices([@ProjectTracker].Status))
);
Sample solution for App.Formulas:
CurrentUser = LookUp(UserProfiles, Email = User().Email);
Sample solution for ProjectsScreen.OnVisible:
If(
IsEmpty(colProjects),
ClearCollect(
colProjects,
AddColumns(
ShowColumns(
Filter(
ProjectTracker,
Status.Value <> "Archived" &&
DueDate >= DateAdd(Today(), -180, Days)
),
"ID", "Title", "Status", "DueDate", "Budget", "ClientID", "Owner"
),
"ClientName",
LookUp(colClients, ID = ThisItem.ClientID, CompanyName)
)
)
)
Sample solution for gallery Items formula:
Filter(
colProjects,
(IsBlank(SearchBox.Text) ||
SearchBox.Text in Title ||
SearchBox.Text in ClientName) &&
(StatusFilter.Selected.Value = "All" ||
Status.Value = StatusFilter.Selected.Value)
)
Notice how the gallery formula now uses || with IsBlank() to handle the empty search state, searches across both Title and ClientName (possible because we're filtering a local collection, not a server data source), and filters by status with an "All" escape valve.
Removing the yellow triangle doesn't guarantee correctness — it guarantees that the operation will be sent to the server. If the SharePoint column you're filtering on isn't indexed, large lists will still return incomplete results or time out. Always ensure that columns used in Filter conditions are indexed in SharePoint list settings.
Concurrent() speeds up parallel operations, but if any single operation inside it is slow, the whole block waits for it. Use Monitor to identify which specific call is slow. Often it's a ClearCollect loading too many columns — apply ShowColumns() to the slow call.
Global variables and collections set in App.OnStart are available on all screens — but only after App.OnStart completes. If you're using App.StartScreen and App.Formulas, named formulas are evaluated lazily when first referenced, which is fine. But if you set a collection in ScreenA.OnVisible and try to use it on ScreenB, it won't exist unless the user visited ScreenA first. Use App.OnStart for data needed everywhere, Screen.OnVisible for screen-specific data.
AddColumns + LookUp against a remote data source runs one query per row. Make sure your LookUp target is a local collection, not the raw SharePoint list. Load your lookup table into a collection first (like colClients in our example), then AddColumns referencing that collection. Collection-to-collection lookups are fast; collection-to-SharePoint lookups are slow.
If you cache data in Screen.OnVisible with an IsEmpty() guard, data never refreshes unless the collection is cleared. Add a refresh button that calls Clear(colProjects) and then triggers the OnVisible logic again. Or add a timestamp check: cache data for a maximum of 10 minutes, then refresh automatically.
// In Screen.OnVisible
If(
IsEmpty(colProjects) ||
DateDiff(gblProjectsLoadTime, Now(), TimeUnit.Minutes) > 10,
ClearCollect(colProjects, /* your filter query */);
Set(gblProjectsLoadTime, Now())
)
SharePoint lists behave differently based on the number of items. SharePoint enforces a List View Threshold of 5,000 items — queries that work on lists under 5,000 items may fail or timeout on larger lists if the filtered column isn't indexed. The fix: ensure all columns used in Filter and Search operations are indexed in SharePoint.
The core insight of this lesson is that performance in Power Apps is really two separate problems wearing the same face. Delegation is a correctness problem: your formulas are being executed in the wrong place, against an incomplete slice of your data. Load time is an experience problem: you're doing too much work before the user sees anything useful. Both problems have the same root cause — treating Power Apps like a traditional local application instead of a distributed system where data lives on servers.
The practical habits to carry forward:
Year(DueDate) = 2024 becomes DueDate >= Date(2024,1,1) && DueDate < Date(2025,1,1).App.Formulas for lazy-loaded named values instead of loading everything in App.OnStart.ShowColumns() to every ClearCollect call to limit payload size.AddColumns at collection load time, not inside gallery item formulas.Learning Path: Canvas Apps 101