A custom action button that recalculates a handful of fields on the current row can run synchronously without a second thought. A custom action that needs to touch ten thousand rows, call an external API per row, or regenerate a large report is a different problem entirely, and the naive synchronous version of that button times out, locks the UI, or trips the web tier's request timeout on a slow client network. Here's how I actually decide between the patterns, and where each one bites.
Start synchronous, and mean it
A PXAction handler runs on the request thread by default, and for the overwhelming majority of custom actions - validate this row, set that field, redirect to a related screen - that's correct and you should not add complexity you don't need:
public PXAction<SOOrder> RecalculateDiscount;
[PXUIField(DisplayName = "Recalculate Discount")]
[PXButton]
protected virtual IEnumerable recalculateDiscount(PXAdapter adapter)
{
var order = Base.Document.Current;
if (order == null) return adapter.Get();
order.UsrDiscountPct = ComputeDiscount(order);
Base.Document.Update(order);
return adapter.Get();
}
The failure mode I actually see is developers reaching for async infrastructure - a scheduled processing screen, a background thread - on an action like this one, purely because "async sounds more scalable." It adds a queue, a status field, a polling UI, and a debugging story, for a button that would have returned in under a second synchronously. Don't.
PXLongOperation: async without leaving the screen
When an action needs real work - a loop over hundreds of rows, an external HTTP call with meaningful latency, generating a large export - but the user is still expected to stay on the screen and watch a progress indicator, PXLongOperation is the right tool. It runs the work on a background thread while returning control to the UI immediately with a progress bar:
public PXAction<SOOrder> SyncToWarehouseSystem;
[PXUIField(DisplayName = "Sync to Warehouse System")]
[PXButton]
protected virtual IEnumerable syncToWarehouseSystem(PXAdapter adapter)
{
var order = Base.Document.Current;
if (order == null) return adapter.Get();
PXLongOperation.StartOperation(Base, () =>
{
// Runs on a background thread. Needs its own graph instance -
// never touch Base's cache from here, it belongs to the request thread.
var graph = PXGraph.CreateInstance<SOOrderEntry>();
var doc = graph.Document.Search<SOOrder.orderNbr>(order.OrderNbr);
CallWarehouseApi(doc);
graph.Document.Current.UsrWarehouseSynced = true;
graph.Save.Press();
});
return adapter.Get();
}
Capturing Base's cache or Base.Document.Current inside the lambda and mutating it directly produces intermittent, hard-to-reproduce corruption, because the request thread's graph and the background thread's execution aren't safe to share. Always create a fresh PXGraph instance inside the delegate, re-fetch the row you need by key, and save through that instance. I've inherited more than one client customization that "usually worked" and occasionally corrupted a save, and every one traced back to this exact mistake.
Automation/scheduled processing screens for genuinely large batches
When the volume is large enough that even a background thread on the web tier is the wrong place to run it - tens of thousands of rows, a nightly reconciliation, anything that should survive an app pool recycle or run unattended on a schedule - that's a job for Acumatica's Automation Schedules against a processing graph, not a button at all. The processing graph pattern (PXProcessing<T>) is built for exactly this: paged row processing, per-row error isolation so one bad row doesn't fail the whole batch, and a status screen users already know how to read. If a "long operation" button is regularly taking minutes and users are learning to just close the tab and check back later, that's the signal it should have been a scheduled processing screen from the start.
Design every async action to be safely re-run
The question I ask before shipping any async action: what happens if the user clicks it twice, or the app pool recycles mid-operation, or the background thread throws halfway through a loop over five hundred rows? A synchronous action that fails mid-way rolls back cleanly inside its transaction. A PXLongOperation that fails at row 300 of 500 has already committed rows 1 through 299 if each row saves independently - so the action needs to be safe to re-run against the same data, typically by checking a status field per row before processing it again, rather than assuming a clean slate on retry.
Wrapping up
Default to synchronous for anything fast - most custom actions are. Reach for PXLongOperation when the user needs to stay on screen through real but bounded work, and always build the background delegate against a fresh graph instance, never Base's. Move to a scheduled processing graph once volume crosses into genuine batch territory, where a button was never the right UI in the first place. And design for partial failure and re-runs from the first version, not as a bug fix after the first client complains about a stuck sync.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.