Acumatica · Customization

Custom Actions in Acumatica — Async Patterns

A custom action button that recalculates a handful of fields on the current row can run synchronously without a second thought.

John Kihiu12 min read

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:

C#
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:

C#
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();
}
The background delegate runs on a fresh graph, not Base - this is the single most common PXLongOperation bug

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.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.