The difference between a flaky app and a solid one is usually error handling. A Patch to SharePoint can fail for a dozen reasons. A lookup misses. A conversion throws. A required field is blank. Another user claims the same unique value half a second before you do. By default those failures surface as ugly banners or silent blanks.
This page covers the patterns that keep it under control, ending with the one most guides skip: what to do when two people save at the same time.
Before you start: turn the feature on
Formula-level error management is on by default for new apps, but older apps may have it disabled. If it is off, IfError and IsError will not behave the way anything on this page describes, and errors come back as blank values instead.
How errors travel
Errors propagate through formulas the way #DIV/0! propagates through a spreadsheet. An error input produces an error output, all the way up the chain.
Mid(Text(1 / 0), 1, 1) // the division error passes through Text() and Mid()Three functions are designed to intercept an error instead of passing it along: IfError, IsError, and IsBlankOrError. Everything else forwards it. You are not catching exceptions at a call site, you are putting a filter somewhere in a pipe.
The four building blocks
IsError: did it fail?
IsError(Value(txtQty.Text)) // true if the text is not a numberIfError: recover with a fallback
IfError( LookUp(Items, ID = varId), { Title: "Not found" } // returned if the LookUp errors)IsBlankOrError: the usual real-world check
// Blank and error are different things, and you almost always// want to treat them the same way in a UI.If(IsBlankOrError(varRate), 0, varRate)Errors(): per-field messages after a failed write
// After a failed Patch or SubmitForm on a data source:Concat(Errors(YourList), Message, Char(10))Use FirstError.Message when you want one line for a notification. Use Errors(DataSource) when several fields failed at once and the user needs to see all of them.
Watch the argument count on IfError
This trips up nearly everyone. The signature is:
IfError(Value1, Fallback1, Value2, Fallback2, ..., DefaultResult)IfError is not if-then-else
Value in order and returns the matching Fallback for the first one that errors. It is not IfError(action, condition, thenDo, elseDo). If you write it that way, your second argument gets evaluated as a replacement value and discarded, and your third argument runs on the success path.// WRONG: this is four arguments, not a condition and two branches.// The retry block runs when SubmitForm SUCCEEDS.IfError( SubmitForm(frmCase), "key" in FirstError.Message, // treated as Fallback1, evaluated and thrown away /* retry block */, // treated as Value2, runs on success false) // RIGHT: branch inside the fallback.IfError( SubmitForm(frmCase), If( FirstError.Kind = ErrorKind.ConstraintViolated, /* retry */, Notify("Save failed: " & FirstError.Message, NotificationType.Error) ))Patch with an error check
Capture the result of a Patch and branch on it. This is the single most valuable pattern on this page.
Set(gblResult, Patch( YourSharePointList, Defaults(YourSharePointList), { Title: txtTitle.Text, Status: { Value: "Active" }, AssignedTo: cmbAssignee.Selected } ));If( IsError(gblResult), Notify("Save failed: " & FirstError.Message, NotificationType.Error), Notify("Saved successfully", NotificationType.Success); Navigate(scrList, ScreenTransition.None))Note the property names. Classic and modern text inputs both expose .Text, not .Value. A ComboBox exposes .Selected for a single record and .SelectedItems for multi-select. Getting this wrong is the most common reason a Patch fails with a type error rather than a data error.
Validate before you write
Cheaper than handling a failure is preventing it. Short-circuit on invalid input with a chained If before the Patch ever runs.
If( IsBlank(txtTitle.Text), Notify("Title is required", NotificationType.Warning), IsBlank(cmbStatus.Selected), Notify("Select a status", NotificationType.Warning), // all valid, save Set(gblResult, Patch(YourSharePointList, Defaults(YourSharePointList), { Title: txtTitle.Text, Status: cmbStatus.Selected } ) ); If( IsError(gblResult), Notify("Save failed: " & FirstError.Message, NotificationType.Error), Notify("Saved", NotificationType.Success) ))Validation prevents mistakes the user can fix. It cannot prevent failures that depend on what other users are doing at the same moment. That is the next section.
Forms: OnSuccess and OnFailure
If you use a Form control with SubmitForm, do not wrap the submit in IfError for ordinary error handling. The form has dedicated behavior properties.
Notify("Saved", NotificationType.Success);Back()Notify("Could not save: " & frmItem.Error, NotificationType.Error)Gate the submit on frmItem.Valid so required-field errors show inline before you attempt the write.
If( frmItem.Valid, SubmitForm(frmItem), Notify("Fix the highlighted fields", NotificationType.Warning))The form also exposes frmItem.ErrorKind, which gives you the same enum value as FirstError.Kind. Use it in OnFailure when you want to react differently to a permission failure than to a conflict. The one time you do wrap SubmitForm in IfError is when you intend to retry the submit yourself. That is the concurrency case below.
Concurrency: when two users save at once
This is the failure mode that validation cannot catch and that most tutorials never mention.
The scenario
You have a SharePoint list with a column that must be unique. Maybe it is a case key like FY26-1043, built from a fiscal year plus an incrementing number. You turned on Enforce unique values on the column, which is the right call.
Your app computes the next number by reading the highest existing one and adding 1. Two users open the form within a few seconds of each other. Both read 1042. Both compute 1043. The first save wins. The second one gets rejected by SharePoint with a constraint violation, and unless you handle it, the user sees a generic red banner and loses their work.
Why the constraint is doing the real work
It is worth being clear about what is protecting your data here. It is not your formula. Any client-side "read the max, add one" is racy by construction, because there is a window between the read and the write during which anyone else can write.
Getting the next number correctly
Two things go wrong in the version of this most people write.
Last(Sort(Filter(Cases, FY.Value = cmbFY.Selected.Value), Created, SortOrder.Ascending)).case_id + 1Max(Filter(Cases, FY.Value = cmbFY.Selected.Value), case_id) + 1Last() and Max() are not delegable against SharePoint
Last() is not delegable. Past the 500-row limit you are taking the last row of a truncated page, not the last row of the list. Max() is not delegable against SharePoint either, so it silently operates on the first page only. Sorting by Created is also the wrong key. It gives you the case_id of the most recently created row, which is only the highest number if nobody ever deleted a record or created one out of order.The delegable version sorts on the number itself and takes the first row:
Coalesce( First( Sort( Filter(Cases, FY.Value = cmbFY.Selected.Value), case_id, SortOrder.Descending ) ).case_id, 0) + 1Sort and Filter both delegate against SharePoint provided case_id and FY are indexed columns. First then takes the top of the sorted result rather than the top of an arbitrary page. Coalesce handles the first record of a new fiscal year, where the filter returns nothing. Index both columns in list settings. Without the index, SharePoint will refuse to sort past 5,000 items and you are back to a truncated page.
The gotcha that breaks the naive retry
Here is the trap. A common retry looks like this:
IfError( SubmitForm(frmCase), Refresh(Cases); Set(gblNextCaseId, /* recomputed max + 1 */); SubmitForm(frmCase) // this may submit the OLD key)Set() does not reach control properties in the same formula
Set() is not synchronous with respect to downstream control properties. The key that gets written comes from a data card whose Update reads a control that reads gblNextCaseId, and the control tree does not necessarily recalculate before the second SubmitForm runs in the same behavior chain. The same reasoning kills the loading spinner: a locLoading toggled true then false in one formula never renders, because the screen does not repaint mid-behavior-formula.There are two ways around it.
Option A: Patch directly, skip the form for the key
The cleanest fix is to not depend on a control's value at all. Compute the key inside the formula, so the retry uses a value you control directly.
With( { wNextId: Coalesce( First(Sort(Filter(Cases, FY.Value = cmbFY.Selected.Value), case_id, SortOrder.Descending)).case_id, 0 ) + 1 }, IfError( Patch(Cases, Defaults(Cases), { Title: txtTitle.Text, FY: cmbFY.Selected, case_id: wNextId, test_key: cmbFY.Selected.Value & "-" & wNextId } ), // first attempt failed, decide whether it is worth retrying If( FirstError.Kind = ErrorKind.ConstraintViolated, Refresh(Cases); With( { wRetryId: Coalesce( First(Sort(Filter(Cases, FY.Value = cmbFY.Selected.Value), case_id, SortOrder.Descending)).case_id, 0 ) + 1 }, IfError( Patch(Cases, Defaults(Cases), { Title: txtTitle.Text, FY: cmbFY.Selected, case_id: wRetryId, test_key: cmbFY.Selected.Value & "-" & wRetryId } ), Notify( "That key was claimed twice while saving. Please try once more.", NotificationType.Error ), Notify("Saved as " & cmbFY.Selected.Value & "-" & wRetryId, NotificationType.Success); Back() ) ), Notify("Save failed: " & FirstError.Message, NotificationType.Error) ), // first attempt succeeded Notify("Saved as " & cmbFY.Selected.Value & "-" & wNextId, NotificationType.Success); Back() ))The nested With matters. A With binding is evaluated once, so reusing wNextId after the Refresh would give you the stale number. The inner With re-reads after the refresh. One retry is the right number: if two collisions happen back to back you have a load problem, not a race, and hammering SharePoint will make it worse.
Option B: keep the form, retry on a timer
If you need the form control for its validation and card layout, break the retry across a repaint using a timer. The timer tick gives the control tree a chance to pick up the new variable before the second submit.
If( !frmCase.Valid, Notify("Fix the highlighted fields", NotificationType.Warning), SubmitForm(frmCase))If( frmCase.ErrorKind = ErrorKind.ConstraintViolated && !locRetrying, Refresh(Cases); Set(gblNextCaseId, Coalesce( First(Sort(Filter(Cases, FY.Value = cmbFY.Selected.Value), case_id, SortOrder.Descending)).case_id, 0 ) + 1 ); UpdateContext({ locRetrying: true }), Notify("Could not save: " & frmCase.Error, NotificationType.Error); UpdateContext({ locRetrying: false }))// AutoStart: false// Repeat: false// Duration: 400// Start: locRetrying// Reset: !locRetrying// OnTimerEnd:UpdateContext({ locRetrying: false });SubmitForm(frmCase)The locRetrying guard is what stops an infinite loop. Without it, a second failure re-enters OnFailure, restarts the timer, and you have built a retry storm. Because the timer does span a repaint, this is also the version where a loading overlay actually works. Bind the overlay's Visible to locRetrying.
Confirm the ErrorKind in your own tenant
ConstraintViolated (8) is what SharePoint returns for a unique-column rejection in most cases, but the connector can surface a conflict as Conflict (6) or Validation (11) depending on the failure path and the column type. Do not guess. Drop a temporary label on the screen and set its Text to frmCase.ErrorKind, force a duplicate by hand, and read what you actually get. It takes two minutes and saves an afternoon.
When to stop doing this in the app
If the key genuinely matters (it appears on a document or gets quoted to a customer), move the generation server-side. A Power Automate flow triggered by the app is a single writer: it reads the max, writes the record, and returns the key, with no window for another client to slip in. Simplest of all: if you do not truly need a formatted key, SharePoint's built-in ID column is already unique, already generated server-side, and already free.
Coalesce for safe defaults
Many "errors" are really just blanks propagating. Coalesce returns the first non-blank value, which kills a whole class of downstream failures.
Coalesce(varUser.FullName, "Unknown user")Coalesce(ThisItem.Amount, 0) * 1.08Coalesce(Param("id"), "new")Note that Coalesce handles blank, not error. For an expression that can throw, wrap it: IfError(Value(txt.Text), 0).
App.OnError: the global net
App.OnError runs for every unhandled error in the app. It cannot fix or replace the error, because by the time it runs the error has already propagated through your formulas. What it can do is log and control how the failure is reported.
Trace($"Error: {FirstError.Message} | Source: {FirstError.Source}");Error(FirstError) // rethrow so the user still sees the bannerLeaving out the Error(FirstError) line suppresses the banner entirely, which is occasionally what you want and usually is not. Silent failure is worse than an ugly banner. To suppress one specific noisy kind and keep everything else:
If(FirstError.Kind <> ErrorKind.Div0, Error(FirstError))Custom errors
You can raise your own errors and catch them like any other.
// RaiseError({ Kind: ErrorKind.Custom, Message: "This report was already submitted for that date" }) // Or with your own numeric kind, so you can branch on it laterError({ Kind: 1001, Message: "Case is locked for editing" })Use values above 1000 for custom kinds so you never collide with a system value Microsoft adds later.
Errors inside ForAll
ForAll does not stop at the first error. Every iteration runs and the errors accumulate, which is usually what you want for a bulk operation but surprises people the first time.
Clear(colFailed);IfError( ForAll(colChanges As c, Patch(YourList, LookUp(YourList, ID = c.ID), { Status: c.Status }) ), ForAll(AllErrors As e, Collect(colFailed, { Message: e.Message })));If( !IsEmpty(colFailed), Notify(CountRows(colFailed) & " of " & CountRows(colChanges) & " rows failed to save", NotificationType.Error))AllErrors is the table form of FirstError and is available in the same scopes.
ErrorKind reference
Every value in the enum, with what actually triggers it in practice.
| ErrorKind | Value | Typically triggered by |
|---|---|---|
| None | 0 | No error |
| Sync | 1 | Data source rejected the operation; read the Message for the real cause |
| MissingRequired | 2 | A required column was omitted or blank in a Patch |
| CreatePermission | 3 | User lacks Add permission on the list or table |
| EditPermissions | 4 | User lacks Edit permission, or the item is checked out to someone else |
| DeletePermissions | 5 | User lacks Delete permission |
| Conflict | 6 | The record changed at the source since you read it (optimistic-concurrency conflict) |
| NotFound | 7 | LookUp or Patch targeted a record that no longer exists |
| ConstraintViolated | 8 | Server-side constraint failed, including a SharePoint Enforce-unique-values column |
| GeneratedValue | 9 | You passed a value for a calculated or auto-generated column |
| ReadOnlyValue | 10 | You wrote to a read-only column (common with SharePoint system columns) |
| Validation | 11 | Column or list validation formula rejected the value |
| Unknown | 12 | Connector returned something Power Fx could not classify |
| Div0 | 13 | Division by zero |
| BadLanguageCode | 14 | Invalid locale tag in Text or Value |
| BadRegex | 15 | Malformed pattern in IsMatch, Match, or MatchAll |
| InvalidFunctionUsage | 16 | Wrong argument count or shape |
| FileNotFound | 17 | LoadData found no saved file |
| AnalysisError | 18 | Compiler could not analyze the formula |
| ReadPermission | 19 | User lacks read access to the source |
| NotSupported | 20 | Operation unavailable on this device or player |
| InsufficientMemory | 21 | Device is out of memory or storage |
| QuoteExceeded | 22 | Storage quota exceeded |
| Network | 23 | Request failed in transit, often intermittent, worth retrying |
| Numeric | 24 | Numeric function given an out-of-domain value, e.g. Sqrt(-1) |
| InvalidArgument | 25 | Argument of the wrong type |
| Internal | 26 | Internal platform error |
| NotApplicable | 27 | Deliberate blank that should be surfaced rather than ignored |
| Custom | n/a | Raised by your own Error() call |
Kinds worth branching on specifically: 6, 8, and 11 for write conflicts and constraints; 3, 4, 5, and 19 for permissions; and 23 for transient network failures.
Rules of thumb
- Wrap anything that can throw:
Patch,Value(),ParseJSON, andLookUpon a maybe-missing row are the usual suspects. - Confirm formula-level error management is on before you rely on any of this.
- Branch on
FirstError.Kind, not onFirstError.Message. Message text is locale dependent and Microsoft rewrites it without warning. - Show a message the user can act on. Include the underlying message rather than a bare "Error."
- Prefer a validation guard over a recovery path. An error you prevent never needs a message.
- Never assume a client-computed unique value is safe. Enforce it at the data source and treat your formula as recovery.
- Retry once, not in a loop, and always with a guard variable.
- Remember that
Set()does not propagate to control properties within the same behavior formula. Break the chain with a timer or bypass the control entirely. - Remember that a loading indicator toggled and untoggled inside one behavior formula never renders.