Scheduled Apex acts as your org's cron job: "Run this class every night at 2 AM." The Apex Flex Queue is the waiting room for these jobs. Because Salesforce only allows 5 batch jobs to run concurrently, up to 100 additional jobs can sit patiently in the Flex Queue until a slot opens up. You schedule the class, and the platform handles the waiting room.
You need to send subscription renewal reminders daily at 2:00 AM. You write a
Schedulable class and set a cron expression: 0 0 2 * * ?. When 2:00 AM hits, what if 9 other heavy batch jobs are already running? No problem. Your reminder job enters the Flex Queue (which holds up to 100 jobs) and waits until one of the 5 active slots becomes available.
Scheduled Apex is the alarm clock. The Flex Queue is the waiting room (100 seats, 5 doors).
Core Concept: How Scheduled Apex Works
Scheduled Apex allows you to run Apex logic on a specific calendar schedule. You implement the Schedulable interface and register the job using System.schedule() alongside a CRON expression.
- The Golden Rule: Keep it thin. Your Schedulable class should generally just launch a Batch or Queueable job and immediately stop.
- Avoid Heavy Logic: Writing complex logic directly inside the
execute(SchedulableContext)method is heavily discouraged because an active Scheduled Apex class is locked and cannot be easily modified or deployed. - The Flex Queue: This is the other half of the scheduling picture. It serves as a buffer that holds up to 100 submitted batch jobs when the 5 concurrent execution slots are full.
- Queue Management: You can actively reorder jobs in the Flex Queue, either programmatically via Apex or manually through the Salesforce Setup UI. It acts as your built-in backpressure buffer.
- Rule: Schedule a thin launcher, not the actual heavy lifting. Let the scheduled class start a Batch and exit.
- Gain: Automated, clock-driven work without external infrastructure. The Flex Queue safely absorbs the overflow.
- Price: A scheduled class (and anything it references) locks deployments. Your release pipeline must unschedule and reschedule around it.
- Limits: 5 concurrent batch jobs. 100 jobs waiting in the Flex Queue. 100 total scheduled jobs permitted per org. A cron entry holds its slot even when idle.
- Mirror (Flow): Schedule-triggered Flows are great for simple, admin-owned record updates, but they cannot retry callouts or launch Batch Apex.
- Later: If you find yourself scheduling 30 recurring jobs, it is time to build one generic scheduler driven by Custom Metadata (see Q&A below).
- At Volume: At month-end, yesterday's nightly job might still be running when tonight's starts. Always query
AsyncApexJobto check for in-flight instances before kicking off a new one.
Core Q&A
Q: What are the operational gotchas of Scheduled Apex that bite in real orgs?
A: There are four major operational traps to watch out for:
- The Deployment Lock: A scheduled class and its dependencies resist deployment. Your release pipeline must physically unschedule the job, deploy the code, and reschedule it. This is why your schedulable class should be a logic-free, thin shell.
- Schedules Don't Deploy: Schedules are org-local state. They do not deploy via changesets. Every sandbox refresh and production deployment requires a post-deployment script or manual setup to recreate the schedules.
- Time Zone Chaos: The CRON execution time zone is based on the user who scheduled the job. If an admin in a different time zone edits the job, a "midnight" run might suddenly trigger at the wrong hour.
- The 100 Job Limit: An org can only have 100 scheduled jobs at once. At scale, this forces you to combine jobs into a single configuration-driven dispatcher.
Follow-Ups (Scenario-Based)
Q1: Five batches are executing, ten are in the Flex Queue, and an urgent recalculation must run NOW. What are your options?
A1: First, reorder the queue. You can move the urgent job to Flex Queue position 1 (either through the Setup UI or using FlexQueue.moveJobToFront()) so it grabs the very next available slot.
- If it is a true emergency, you can use
System.abortJob(jobId)to kill a lower-priority running batch to free up a slot immediately. - However, aborting is only safe if the killed batch is designed to be safely restarted without data corruption—a design requirement this scenario heavily argues for.
- Strategic Fix: Chronic Flex Queue contention is a symptom, not the disease. You need to combine batches or move appropriate work over to Queueable Apex, which operates on its own separate concurrency budget.
Q2: Design a scheduling architecture for 30 recurring jobs without drowning in 30 scheduled classes.
A2: Use a Custom Metadata Type (CMT) dispatcher pattern.
- Create one single, generic
Schedulableclass. - Create a Custom Metadata Type holding the definitions for your 30 jobs: target class name, CRON schedule, active flag, and parameters.
- Set the generic scheduler to wake up frequently (e.g., every 15 minutes using a few staggered CRON entries).
- When it wakes up, it reads the CMT. If a job is due, it uses
Type.forName()to instantiate the target class dynamically and dispatches it as a Queueable or Batch. - Why this wins: You consume only a fraction of the 100-job org limit. Adding a new job just requires creating a metadata record, not a code deployment. The deployment lock problem is restricted to one thin class.
Q (Compare): Scheduled Apex vs a Schedule-Triggered Flow — who gets the nightly job?
A: Use Flow for simple, admin-owned record updates running on a timer (e.g., "Flag all draft deliveries as stale every night"). Use Scheduled Apex when the task involves complex SOQL queries that Flow cannot shape, API callouts that require programmatic retry logic, or when you need to trigger a heavy Batch Apex job. For example, a nightly billing run should be Scheduled Apex launching a Batch.