Skip to content

Develop Custom Scheduled Jobs

⏱ 15 minutes intermediate

Many CMS operations need to run periodically without manual intervention β€” cleaning up expired content, syncing data from external systems, generating reports, or rebuilding search indexes. Scheduled jobs run in the background on a configurable schedule and report their status in the admin UI.

  1. Create a class that inherits from ScheduledJobBase
  2. Implement the Execute method with your business logic
  3. Support cancellation for long-running jobs
  4. Configure the schedule in the CMS admin
A scheduled job that cleans expired content
csharp
using Optimizely.Cms.Core;
using Optimizely.Cms.Core.Repositories;
using Optimizely.Cms.Core.ScheduledJobs;

namespace MySite.ScheduledJobs;

[ScheduledPlugIn(
    DisplayName = "Clean Expired Content",
    Description = "Removes content past its expiration date",
    GUID = "c1d2e3f4-a5b6-7890-cdef-112233445566")]
public class CleanExpiredContentJob : ScheduledJobBase
{
    private readonly IContentRepository _contentRepo;
    private bool _stopSignaled;

    public CleanExpiredContentJob(
        IContentRepository contentRepo)
    {
        _contentRepo = contentRepo;
        IsStoppable = true;
    }

    public override string Execute()
    {
        var removedCount = 0;
        var descendants = _contentRepo
            .GetDescendents(ContentReference.RootPage);

        foreach (var contentLink in descendants)
        {
            if (_stopSignaled) 
                return $"Job stopped. Removed {removedCount} items.";

            var content = _contentRepo.Get<IContent>(
                contentLink);

            if (content is IVersionable versionable &&
                versionable.StopPublish < DateTime.UtcNow)
            {
                _contentRepo.Delete(
                    contentLink, forceDelete: true);
                removedCount++;
            }
        }

        return $"Completed. Removed {removedCount} expired items.";
    }

    public override void Stop()
    {
        _stopSignaled = true;
    }
}

Key points:

  • The [ScheduledPlugIn] attribute registers the job with the CMS. The GUID must be unique and stable across deployments.
  • Execute returns a string that displays as the job status message in admin.
  • Set IsStoppable = true and implement Stop() so administrators can cancel long-running jobs.

Scheduled jobs support constructor injection. The CMS resolves dependencies from the service container.

Job with injected services
csharp
using Optimizely.Cms.Core.ScheduledJobs;
using Microsoft.Extensions.Logging;

namespace MySite.ScheduledJobs;

[ScheduledPlugIn(
    DisplayName = "Sync External Data",
    Description = "Pulls product data from external API",
    GUID = "d2e3f4a5-b6c7-8901-defa-223344556677")]
public class ExternalDataSyncJob : ScheduledJobBase
{
    private readonly IExternalApiClient _apiClient;
    private readonly IContentRepository _contentRepo;
    private readonly ILogger<ExternalDataSyncJob> _logger;

    public ExternalDataSyncJob(
        IExternalApiClient apiClient,
        IContentRepository contentRepo,
        ILogger<ExternalDataSyncJob> logger)
    {
        _apiClient = apiClient;
        _contentRepo = contentRepo;
        _logger = logger;
    }

    public override string Execute()
    {
        _logger.LogInformation("Starting external data sync");

        var products = _apiClient.GetProducts();
        var synced = 0;

        foreach (var product in products)
        {
            SyncProduct(product);
            synced++;
            OnStatusChanged(
                $"Synced {synced} of {products.Count}");
        }

        _logger.LogInformation(
            "Sync complete: {Count} products", synced);
        return $"Synced {synced} products successfully.";
    }

    private void SyncProduct(ProductDto product)
    {
        // Create or update content from external data
    }
}

Call OnStatusChanged during execution to update the progress message visible in the admin UI.

For jobs that process many items, update the status regularly so administrators can monitor progress.

Progress reporting
csharp
public override string Execute()
{
    var items = GetItemsToProcess();
    var total = items.Count;
    var processed = 0;

    foreach (var item in items)
    {
        if (_stopSignaled)
            return $"Stopped at {processed}/{total}.";

        ProcessItem(item);
        processed++;

        if (processed % 50 == 0)
        {
            OnStatusChanged(
                $"Processing: {processed}/{total} " +
                $"({processed * 100 / total}%%)");
        }
    }

    return $"Done. Processed {total} items.";
}

After deploying, configure the job in the CMS admin:

  1. Navigate to Admin > Scheduled Jobs
  2. Find your job by its display name
  3. Set the schedule interval (daily, hourly, or a custom cron expression)
  4. Enable the job and click Save
  5. Optionally click Start Manually to test the first run
IssueCauseFix
Job not listed in adminMissing [ScheduledPlugIn] attributeAdd the attribute with a unique GUID
Job runs but status is emptyExecute returns nullAlways return a status message string
Job cannot be stoppedIsStoppable not setSet IsStoppable = true in the constructor
Timeout on long jobsDefault timeout too shortBreak work into batches and track progress