Scheduled Apex is the cron of the platform — it should orchestrate work, not do it. The mature pattern is: fire on schedule, enqueue Batch or Queueable for the heavy lifting. Below is the working checklist senior Salesforce devs apply.
1. Keep execute() thin
Your Schedulable should resolve to a few SOQL queries plus a Database.executeBatch or System.enqueueJob call. Heavy DML, callouts, and looping go in the async job that’s kicked off, not in the Schedulable itself.
public class NightlyClose implements Schedulable {
public void execute(SchedulableContext sc) {
Database.executeBatch(new NightlyCloseBatch(), 200);
}
}
2. Treat callouts as forbidden in execute()
Synchronous callouts are blocked from Schedulable code. Always route to Queueable with Database.AllowsCallouts, @future(callout=true), or Batchable with Database.AllowsCallouts.
3. Watch the 100-job cap
Salesforce limits active scheduled Apex jobs to 100 per org. Before scheduling, count current jobs:
Integer waiting = [SELECT COUNT() FROM CronTrigger WHERE State = 'WAITING'];
if (waiting >= 95) {
throw new IllegalStateException('Too many scheduled jobs, cleanup needed');
}
Reuse a single Schedulable rather than spawning a new one per record.
4. Use System.scheduleBatch when you can
For “run this batch once at time X” cases, System.scheduleBatch(new MyBatch(), 'job-name', delayMinutes) skips the Schedulable boilerplate entirely.
5. Make scheduled classes idempotent
If a job fires twice (manual run plus scheduled fire, or retry after error), the result should be the same. Stamp processed records with a “Last Run At” timestamp or external Id so the next invocation skips already-handled rows.
6. Don’t change the schedulable signature after deployment
Once a class is scheduled, its API signature is frozen in the cron job blueprint. If you change the class name or method signature, the scheduled instance fails. Pattern: abort the job, deploy, re-schedule during release windows.
7. Name jobs deterministically
When you call System.schedule('My Nightly Job', cronExpr, new MyClass()), the second argument is the job name. Use a stable, descriptive name so monitoring tools and ops can identify it.
8. Provide a manual rescheduler
Build a Setup-only Lightning page or executeAnonymous snippet to abort and reschedule all jobs. Saves admins from clicking through Setup → Scheduled Jobs after deploys.
public class JobScheduler {
public static void schedule() {
// Abort prior instance if present
for (CronTrigger ct : [SELECT Id FROM CronTrigger WHERE CronJobDetail.Name = 'Nightly Close']) {
System.abortJob(ct.Id);
}
System.schedule('Nightly Close', '0 0 1 * * ?', new NightlyClose());
}
}
9. Test with Test.startTest() and System.schedule
@isTest
static void schedules() {
Test.startTest();
String jobId = System.schedule('Test Close', '0 0 1 * * ?', new NightlyClose());
Test.stopTest();
System.assert(jobId != null);
}
Test.stopTest() forces synchronous execution of the scheduled job in tests so you can assert side effects.
10. Monitor failures
Watch AsyncApexJob.Status = 'Failed' and CronTrigger.State = 'ERROR'. Surface those into a custom report or send a Slack/email alert from a daily scheduled “monitor” job. Silent failures in nightly jobs are the most common cause of “why didn’t this run last weekend?” tickets.
Common follow-ups
- Cron format? — Seconds Minutes Hours Day-of-Month Month Day-of-Week (optional Year). Example:
0 0 2 * * ?runs every day at 2 AM. - Can two scheduled jobs run at the same time? — Yes, subject to async governor limits.
Verified against: Apex Developer Guide — Apex Scheduler. Last reviewed 2026-05-17 for Spring ‘26.