Skip to content

Job Timeline ​

TL;DR ​

Tag a job with the record it works on, give it a name, and the record page shows every job that ran, runs or will run for that record. Failed steps can be requeued from there, in the original order, with their dependencies rebuilt.

apex
Async.queueable(new ReserveStockJob(lines))
    .name('Reserve stock')
    .chainName('Order fulfilment pipeline')
    .linkRecord(order)
    .linkRecords(lines)
    .chain(new CreateInvoiceJob(order)).name('Create invoice').linkRecord(order)
    .chain(new CommissionSplitJob(order)).name('Commission split').linkRecord(order).retry(2)
    .chain(new AccountRollupJob(order.AccountId))
        .name('Recalculate account rollups')
        .linkRecord(order)
        .dependsOn(Async.afterPrevious().succeeded())
    .enqueue();

Place the Async Jobs component on the Order record page in App Builder and assign AsyncResultAccess to whoever should see it. That is the whole setup.

The Async Jobs card on a record page, with a chain running and the In flight chip lit

Linking a job to a record ​

linkRecord(...) and linkRecords(...) on the builder accept what you already have in hand: an SObject, an Id, a List<SObject>, a List<Id> or a Set<Id>. Duplicates collapse, order is kept, and a requeued job reads its links back from the stored rows. On a chunk run the builder links go on the first page; a page links its own records from inside work().

A job can also point at a record that lives somewhere else: an ERP customer, a payment, a ticket. linkExternalRecord('NetSuite', 'Customer', '4711') stores the key with the source system and object name you pass, so that system can later ask what ran for its record:

soql
SELECT AsyncResult__r.Name, AsyncResult__r.Status__c, AsyncResult__r.JobName__c
FROM AsyncResultLink__c
WHERE Source__c = 'NetSuite' AND Key__c = '4711'

Only the key is required. source and objectName are stored exactly as given and stay blank when you pass nothing; the framework never guesses that a key is a Salesforce Id. Source__c is an open picklist with Salesforce as its one packaged value, add your own systems in Setup if you want them on reports. linkExternalRecords(List<Async.RecordLink>) takes many at once.

A job often discovers related records while it runs. Link them from inside work():

apex
public override void work() {
    Account parent = [SELECT Id FROM Account WHERE Id = :order.AccountId];
    Async.linkRecord(parent);
}

Async.linkRecord(...), linkRecords(...) and linkExternalRecord(...) only buffer links on the running job. Nothing is written until the job settles, so a job that calls out afterwards is not affected. Outside a running job the call throws; before the job runs, use the builder.

Linking forces a result row. A job linked to a record always writes its AsyncResult__c row, whatever CreateResult__c says. You linked it so it shows up on the record; a row is how it shows up. The links themselves are rows of AsyncResultLink__c, one per result and record: Key__c (the Id or the external key, indexed so a record page or an integration can select by it), Source__c (Salesforce for linkRecord(s), as given for linkExternalRecord(s)) and ObjectName__c (the API name for a Salesforce Id, as given otherwise). A record linked twice, once from the builder and again from work(), is still one row: UniqueKey__c hashes source and key per result, so the database rejects the second and the job carries nothing extra through the chain. Each row counts 2 KB of data storage, like any record, and goes with its result when the cleanup batch deletes it.

MaxRecordLinks__c on QueueableJobSetting__mdt caps the links stored per job. Blank means 5000, which is also the ceiling. Links beyond the limit are dropped with a warning in RetryHistory__c, and the warning names the limit, the job and how many it was given.

Names ​

name('Reserve stock') is the display name of one job, stored in JobName__c. Without it the class name is shown. chainName('Order fulfilment pipeline') names the whole chain: set it on any job, the last one set wins, and every result of the chain carries it in ChainName__c. Both names travel with the payload, so a requeued run keeps them.

Lifecycle: seeing a chain in flight ​

By default a result row is written when a job settles. Turn TrackJobProgress__c to Yes on QueueableJobSetting__mdt and the row is written when the job is enqueued, with Status__c = ENQUEUED, and updated in place when it settles. A chain of seven jobs then shows seven rows the moment enqueue() returns: the first with its SalesforceJobId__c, the rest waiting.

ColumnSet when
ENQUEUEDthe job is handed to the chain; SalesforceJobId__c fills in when the platform takes it
StartedAt__cwork() started on the final attempt
FinishedAt__cthe job settled, so FinishedAt__c - StartedAt__c is the duration
NextAttemptAt__cwhen the next run is due: a retry, or a delayed job already enqueued; cleared when it settles
RetryAttempts__cupdated after each failed attempt, so a row reads "attempt 2 of 3" while retrying

Yes implies a row for every outcome of that job, the same cost as CreateResult__c. Blank falls back to All; No on a job record overrides Yes on All, so a noisy job can opt out.

Nothing is written inside work(). The ENQUEUED rows are inserted right before System.enqueueJob, which already blocks a later callout in the same transaction, and the terminal update runs in the finalizer. A job that calls out is not affected either way.

"Running" is not a stored status. The record page reads AsyncApexJob.Status for every ENQUEUED row that has a job id and shows Queued, Running or Aborted from that. A job aborted with System.abortJob keeps its ENQUEUED row until AsyncResultCleanupBatch removes it. A job whose platform status says Completed or Failed while its row is still ENQUEUED shows as Finishing: the finalizer that writes the row runs in its own transaction, seconds later.

A chain's badge follows its lifecycle, not the mix of its steps: QUEUED until the first step starts, RUNNING from then until every step has settled and every page of a chunk run is there, then COMPLETED, FAILED or STOPPED. The mark in the badge tells what the chain is doing now, a pulsing dot while a step executes, a hollow dot while the platform holds the next one, a refresh arrow while a retry waits, and a red count next to the badge is the number of steps failed so far. A chunk run that still misses pages but has been quiet for ten minutes counts as finished.

The record page component ​

Async Jobs targets any record page, in Lightning and in Aura-based Experience sites. One card per chain, newest first by the time the chain was created. A chain never moves when it finishes, so the row you are watching stays where it is, and only a new run shifts the list. The In flight chip turns blue with a pulsing dot while something runs: click it to see only those chains. A chain that finishes while the filter is on stays visible until you leave the filter, so the chip count can be lower than the rows shown. The card opens a chain by itself only on the first page of All, only when nothing is open, and only the first chain that is running; it never does so under a filter or on a later page. What you open or close stays that way across filters and pages, and an open chain stays open after it settles. A chain that starts while another is open only gets a pulsing New badge and a note in the live line, unless Follow new runs is on (an App Builder attribute and a switch next to the live line): then the newcomer opens and the others collapse, for a card left open on a screen. Every other chain collapses to two lines, the name and badge, then a line with the relative start time and duration and one dot per step, a chunk run as a small strip worth three dots. When those do not fit in 16 dots, or the chain is only chunk runs, a progress bar replaces them, so no step is ever left out of the picture. Click the header row or the chevron to toggle; the header you clicked stays where it is, and collapsing also folds any run of completed steps you had opened. Each step shows its glyph, name, status, up to two facts (priority, finalizer of, added by, chunk pages, dependency, requeued), timing and linked records as chips that open the record in a new tab; the record whose page shows the card gets no chip, every step here is linked to it. A blue outline marks the step the platform holds right now: the one running, or, before anything runs, the one waiting in its queue or for its retry. Click anywhere on a step for its details: class, custom and Salesforce job ids, position, attempts, timings, dependencies, the exception, the retry history and a link to the result record. A requeued step links to its replay: in place when the replay is loaded, otherwise to its result record.

Collapsed chains: one dot per step, a small strip for a chunk run, a progress bar when the steps do not fit

A step whose Salesforce job finished while its row is still ENQUEUED shows as Finishing: the finalizer that writes the result runs in its own transaction, often seconds later.

The toolbar has a range menu (24 hours, 7 days, 30 days, all time), refresh, the legend (a panel over the list, so the card keeps its height; Escape or a click outside closes it) and View all results, which opens the By chain list view. The chips count every chain in the range, not just the page, and filter the list. Chains come in blocks of 50 so the footer pager answers in memory. The list has a fixed height and pages hold as many collapsed chains as fit in it, so a page never scrolls until you open a chain. The refresh button reloads in place.

App Builder attributes:

AttributeDefaultEffect
Card titleAsync Jobs
Default range in days7the range selected when the card opens; blank means 7
Sequences per page0chains per page; 0 fits as many collapsed chains as the height allows
Max height in pixels600the fixed height of the list; 0 lets the card grow
Turn off live refreshoffby default the card polls while a chain is in flight
Follow new runsoffa chain that enters in flight opens by itself and the others collapse

Live refresh is polling, not Platform Events: every 3 seconds while a job runs or finishes, every 10 while the chain is queued, and when everything in flight is a scheduled retry or a delayed job, not before the earliest is due (10 seconds to 5 minutes). The live line says Live or Idle, with the cadence in its tooltip, and spells out only what would otherwise look like silence: the clock time of the next check during a retry wait, a paused tab, a stop after failed checks, a stale card. It pauses while the tab is hidden and checks the moment the tab is back, keeps going through a failed check and stops after three in a row or after 30 minutes. After a requeue it keeps watching for two minutes even when nothing looks in flight, because a replay without progress tracking has no rows until it settles. An idle card that has not checked for ten minutes turns the refresh button blue and says so in the live line. A Platform Event subscription per open tab would spend the org's delivery allocation, which is shared with Change Data Capture and every integration, and a subscription cannot be filtered to one record. A new step that appears while the card is open slides in with a New badge; a step whose status changed flashes once.

An open chain sits on a light panel with a line through its steps, so the next chain is easy to tell apart. The steps follow the order the chain runs them, and a finalizer branches off its parent. A job added while you watch, say one with a high priority, slides in at the place it will run with a New badge. Open the card later and the same job just sits in that place, with its added by tag. A tag that names another job (after Commission split succeeded, added by, finalizer of) highlights that job's row on hover or keyboard focus. A row shows two tags and keeps the rest behind a +N with their text on hover.

While a chunk run is alive the chain header counts its pages. Once it has ended in a chain with other steps, the run counts as one step and the line under the name gives 6 steps · 16 pages. A chunk row with failed pages lists each page with its error and a link to its result, pages sharing one error grouped together, and its other link opens the first page.

A failed chunk run: the failing page with its own error and a link to its result

The card adapts to its own width, not the screen's, so it works in a narrow sidebar column. Below 640 px the buttons stay on the first row of each chain: requeue shrinks to an icon with the count, the range to 7 days and View all results to an icon. Very narrow cards hide the footer's brand text before the pager.

The same card in a narrow record page sidebar

The card holds up to 50 chains in the browser, so pages inside that block change without a server call. With up to 50 chains on the record a filter applies at once to what is loaded, then a quiet refresh updates the view the way a live update does. If that refresh fails, the list stays and the live line says so. Beyond 50 chains the browser cannot filter alone: the rows stay until the answer arrives, and a thin blue line under the filters shows when the wait passes 300 ms. Background refreshes never show it.

What the card refuses to render without a way out:

CapBeyond it
20 steps on an expanded cardthe first 3, the last 2 and every step that is not completed stay; each run of completed steps folds into one line that opens on click
200 steps fully expandeda note points to the first result record
60 chunk pages as squaresa bar with page counts
10 failed pages listed on a chunk rowa count of the rest
3 linked records as chips, the page record excludeda +N chip, 20 when opened; a chunk row shows the union of its pages; a record in another system is a grey chip with object and key, no link
300 characters of exceptiona more link
40 nodes on the chain mapruns of completed nodes collapse into one that opens on click

The viewer needs read on AsyncResult__c and AsyncResultLink__c plus access to the controller class. The AsyncResultAccess permission set grants all of it. Without it the card says so by name.

Requeue from the timeline ​

A failed chain opened: the failed step with its error and retry history, dependent steps skipped, and the requeue buttons

The requeue actions show only for a viewer holding the AsyncRequeue custom permission, shipped in the AsyncRequeueAccess permission set. Async.requeue in Apex stays ungated like every other Async call.

Requeue failed steps appears once the chain has settled. It asks for confirmation, then collects every FAILED and skipped row of the chain that has a stored payload and replays them as one new chain, in the original order. A step whose payload was not stored is listed in the outcome, never silently skipped. The new chain appears at the top of the card with a Replay of badge, and the source rows switch to Requeued.

Dependencies travel with the row in Dependencies__c and are rebuilt on replay:

The dependency points atThen
a row in the same requeue callthe replayed dependent depends on the replayed target
a row outside the call whose outcome satisfied itthe dependency is dropped and reported in droppedDependencyByResultId
a row outside the call whose outcome did not satisfy itthe dependent is skipped with a reason; requeue both, or enqueue it fresh
a row that no longer existsthe dependent is skipped with a reason

The chain map ​

The ▾ menu on a card opens the chain as a pipeline: one node per step in the order the framework will run them, with the name in full, position, duration or state, priority and who inserted it. Dependencies are drawn as elbow connectors above the nodes, green when met, red and dashed when not, grey while pending, each labelled with the rule. Finalizers hang under their parent, a chunk run shows its page strip, runtime inserts are dashed. The map opens on the first failed node, or the one running now; click any node for its details. The map shows the order as sorted now; a job chained later at runtime appears on the next refresh.

The chain map: one node per step, with dependency arcs marked met or not met

Running a job next in the chain ​

Inside a running job, nextInChain() places a new job right after the current one, before everything already waiting, whatever priority the waiting jobs carry:

apex
public class CreateInvoiceJob extends QueueableJob {
    public override void work() {
        Invoice__c invoice = createInvoice();
        if (invoice.NeedsTaxCalculation__c) {
            Async.queueable(new CalculateTaxJob(invoice))
                .name('Calculate tax')
                .linkRecord(invoice)
                .nextInChain()
                .enqueue();
        }
    }
}

Finalizers of the current job still run first. Two nextInChain() calls from one job run in call order, and a job inserted by an inserted job runs before its siblings. Inside a chunk page the inserted job runs after that page and before the next one. nextInChain() cannot be combined with priority(), and outside a running job it throws: there, chain() and enqueue() already run jobs in order. The row records who added it in AddedByCustomJobId__c, the same as for a job a running job adds with chain() or enqueue().

Delayed jobs ​

A job with delay(n) is enqueued when its turn comes, so its delay starts when the job before it finishes. The card only shows a clock time it can actually know:

StateChain badgeChain lineJob row
Nothing has started, the first job is delayedDELAYEDstarts at 10:05Delayed, until 10:05
Running, a later job has a delayRUNNINGrunning 2 minEnqueued, delay 10 min
Running, the next job is enqueued and waitingRUNNING | DELAYEDnext step at 10:18Delayed, until 10:18
Same, and a job has already failedRUNNING | ✕ 1 | DELAYEDnext step at 10:18Delayed, until 10:18

The time is the earliest the job can start. If it passes and the job has not started, the org is busy: the row goes back to Queued and the chain drops DELAYED. delayBetweenChunks works the same way between pages. A retry keeps its own Retrying state. A job scheduled with Async.schedulable is not delayed: it has no row until the schedule fires.

Fields added to AsyncResult__c ​

FieldHolds
JobName__c, ChainName__cthe names
ChainSequence__cposition inside the chain, in the order it runs, and the requeue order. It can change only while the job waits
Priority__cthe priority, when set
ParentCustomJobId__cfor a finalizer, the job it was attached to
AddedByCustomJobId__cfor a job added while another job ran, the job that added it
ChunkRunId__c, ChunkPage__c, ChunkPages__cfor a chunk page, its run and position
DelayMinutes__cthe delay set on the job; a backoff between retries does not count
Dependencies__cthe dependsOn rules as JSON
StartedAt__c, FinishedAt__c, NextAttemptAt__ctiming

Every field is on the page layout, the record page and in AsyncResultAccess. The object has a tab and four list views: Failed (last 7 days), In flight, Replayable and By chain.

On a packaged install ​

The component, the permission sets and the custom permission ship in the package. Place Async Jobs from App Builder, assign btcdev__AsyncResultAccess to viewers and btcdev__AsyncRequeueAccess to operators. Requeue from the timeline needs the same AsyncJobSerializer registration as Async.requeue(), see Requeue.