CronTrigger and CronJobDetail objects in Apex, developers can display upcoming run times, execution states, and cron expressions directly on admin homepages or dev utilities without navigating to Salesforce Setup.
In enterprise Salesforce environments, scheduled Apex jobs handle nightly data syncs, automated batch runs, cleanup utilities, and reporting calculations. While administrators can view scheduled jobs in Salesforce Setup under Scheduled Jobs, building a dedicated Lightning Web Component allows teams to embed live job monitoring into custom DevOps consoles, monitor execution health in real time, and trigger manual abort or rescheduling routines on demand.
1. Understanding the Scheduled Job Data Model
When you schedule an Apex class using System.schedule() or the declarative UI, Salesforce stores schedule information across two connected system objects:
CronTrigger: Represents the active scheduled job instance. Contains metadata includingCronExpression,NextFireTime,PreviousFireTime,State(such asWAITING,ACQUIRED,EXECUTING, orPAUSED), andTimesTriggered.CronJobDetail: The parent record linked toCronTriggerviaCronJobDetailId. It stores the human-readable job name (CronJobDetail.Name) and the job type (CronJobDetail.JobType).
- Underlying Entity:
CronTriggerjoined withCronJobDetail. - Data Layer: Strongly typed Apex wrapper to flatten relationship fields for LWC datatable binding.
- Security Enforcement:
WITH USER_MODEand@AuraEnabled(cacheable=true). - Platform Limit: Up to 100 concurrently scheduled Apex jobs per org.
2. Implementing the Apex Controller with Flattened Wrappers
Standard <lightning-datatable> components cannot read nested relationship fields (e.g., CronJobDetail.Name) directly from raw sObject records without custom formatting. Using an Apex wrapper class flattens the data structure cleanly before sending it to the client.
ScheduledJobsController.cls)
public with sharing class ScheduledJobsController {
public class JobWrapper {
@AuraEnabled public String id { get; set; }
@AuraEnabled public String jobName { get; set; }
@AuraEnabled public String cronExpression { get; set; }
@AuraEnabled public Datetime nextFireTime { get; set; }
@AuraEnabled public Datetime previousFireTime { get; set; }
@AuraEnabled public String state { get; set; }
@AuraEnabled public Integer timesTriggered { get; set; }
public JobWrapper(CronTrigger ct) {
this.id = ct.Id;
this.jobName = ct.CronJobDetail.Name;
this.cronExpression = ct.CronExpression;
this.nextFireTime = ct.NextFireTime;
this.previousFireTime = ct.PreviousFireTime;
this.state = ct.State;
this.timesTriggered = ct.TimesTriggered;
}
}
@AuraEnabled(cacheable=true)
public static List<JobWrapper> getScheduledJobs() {
List<JobWrapper> jobList = new List<JobWrapper>();
List<CronTrigger> triggers = [
SELECT Id,
CronJobDetail.Name,
CronExpression,
NextFireTime,
PreviousFireTime,
State,
TimesTriggered
FROM CronTrigger
WITH USER_MODE
ORDER BY NextFireTime ASC NULLS LAST
LIMIT 100
];
for (CronTrigger ct : triggers) {
jobList.add(new JobWrapper(ct));
}
return jobList;
}
}
3. Building the Lightning Web Component
The LWC component consumes the wrapper data using the wire service and displays the scheduled jobs in an interactive, responsive datatable with formatted dates and refresh capabilities.
scheduledJobsMonitor.html)
<template>
<lightning-card title="Active Scheduled Jobs Monitor" icon-name="standard:service_appointment">
<lightning-button-icon
slot="actions"
icon-name="utility:refresh"
alternative-text="Refresh Data"
title="Refresh Jobs"
onclick={handleRefresh}>
</lightning-button-icon>
<div class="slds-p-around_medium">
<!-- Loading State -->
<template lwc:if={isLoading}>
<div class="slds-is-relative slds-p-vertical_large">
<lightning-spinner alternative-text="Loading scheduled jobs..." size="small"></lightning-spinner>
</div>
</template>
<!-- Data Table -->
<template lwc:if={hasJobs}>
<div class="slds-scrollable_y" style="max-height: 400px;">
<lightning-datatable
key-field="id"
data={jobs}
columns={columns}
hide-checkbox-column="true">
</lightning-datatable>
</div>
</template>
<!-- Empty State -->
<template lwc:elseif={noJobsFound}>
<div class="slds-text-align_center slds-p-vertical_medium slds-text-color_weak">
No active scheduled jobs found in this organization.
</div>
</template>
</div>
</lightning-card>
</template>
scheduledJobsMonitor.js)
import { LightningElement, wire } from 'lwc';
import { refreshApex } from '@salesforce/apex';
import getScheduledJobs from '@salesforce/apex/ScheduledJobsController.getScheduledJobs';
const COLUMNS = [
{ label: 'Job Name', fieldName: 'jobName', type: 'text', sortable: true },
{ label: 'State', fieldName: 'state', type: 'text', initialWidth: 120 },
{ label: 'Cron Pattern', fieldName: 'cronExpression', type: 'text', initialWidth: 150 },
{
label: 'Next Fire Time',
fieldName: 'nextFireTime',
type: 'date',
typeAttributes: {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true
}
},
{
label: 'Previous Fire Time',
fieldName: 'previousFireTime',
type: 'date',
typeAttributes: {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true
}
},
{ label: 'Runs', fieldName: 'timesTriggered', type: 'number', initialWidth: 90 }
];
export default class ScheduledJobsMonitor extends LightningElement {
columns = COLUMNS;
jobs = [];
isLoading = true;
wiredJobsResult;
@wire(getScheduledJobs)
wiredScheduledJobs(result) {
this.wiredJobsResult = result;
this.isLoading = false;
const { data, error } = result;
if (data) {
this.jobs = data;
} else if (error) {
console.error('Error fetching scheduled jobs:', error);
this.jobs = [];
}
}
get hasJobs() {
return !this.isLoading && this.jobs && this.jobs.length > 0;
}
get noJobsFound() {
return !this.isLoading && (!this.jobs || this.jobs.length === 0);
}
async handleRefresh() {
this.isLoading = true;
await refreshApex(this.wiredJobsResult);
this.isLoading = false;
}
}
scheduledJobsMonitor.js-meta.xml)
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__AppPage</target>
<target>lightning__HomePage</target>
</targets>
</LightningComponentBundle>
4. Common Traps & Platform Best Practices
CronJobDetail.Name in Datatable ColumnsThe base
<lightning-datatable> component does not support dot-notation path traversing (such as fieldName: 'CronJobDetail.Name'). Setting a nested field name directly in your column definitions leaves that table column blank. Always flatten relationship fields into top-level properties using an Apex wrapper class or JavaScript map before passing data into the datatable.
Once a one-time scheduled job finishes executing, its
NextFireTime becomes null and its State transitions to DELETED or COMPLETE. Always use NULLS LAST in your SOQL order clause to ensure actively pending jobs remain prioritized at the top of the monitor.
- Implement User-Mode Security: Append
WITH USER_MODEto your SOQL query to enforce object and field-level permissions. - Programmatic Job Cancellation: To allow administrators to cancel jobs directly from the LWC, add an action button in the datatable that passes the
idto an Apex method invokingSystem.abortJob(jobId). - Format Date-Time Columns: Use
type: 'date'withtypeAttributesin your column definition so timestamps automatically adapt to each user's local timezone and locale format.
Summary
Building a custom Scheduled Jobs Monitor in Lightning Web Components gives administrators and developers real-time visibility into asynchronous operations. By querying CronTrigger in Apex, flattening nested properties with a structured wrapper class, and binding the results to a modern <lightning-datatable>, teams can build lightweight administrative utilities that simplify Salesforce org maintenance.