Skip to main content
Scheduled jobs allow you to run background tasks on a recurring schedule. They’re useful for cleanup operations, data synchronization, report generation, and other periodic maintenance tasks.

What is a Scheduled Job?

A scheduled job:
  • Runs automatically on a defined schedule (cron expression)
  • Executes in the background without blocking requests
  • Has access to the dependency injection container
  • Can execute workflows and access services
  • Is defined using a configuration export

Creating a Basic Scheduled Job

1

Create the Job File

Create a file in src/jobs/ with a default export function and schedule configuration:
src/jobs/cleanup-expired-brands.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export default async function cleanupExpiredBrands(
  container: MedusaContainer
) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)

  logger.info("Starting cleanup of expired brands")

  // Your cleanup logic here
}

export const config = {
  name: "cleanup-expired-brands",
  schedule: "0 0 * * *", // Run daily at midnight
}
2

Access Services

Resolve services from the container:
import { BRAND_MODULE } from "../modules/brand"
import { IBrandModuleService } from "../modules/brand/types"

export default async function cleanupExpiredBrands(
  container: MedusaContainer
) {
  const logger = container.resolve("logger")
  const brandService = container.resolve<IBrandModuleService>(BRAND_MODULE)

  const expiredDate = new Date()
  expiredDate.setMonth(expiredDate.getMonth() - 6)

  const brands = await brandService.listBrands({
    is_active: false,
    updated_at: {
      $lt: expiredDate,
    },
  })

  if (brands.length > 0) {
    const ids = brands.map((b) => b.id)
    await brandService.deleteBrands(ids)
    logger.info(`Deleted ${brands.length} expired brands`)
  }
}

export const config = {
  name: "cleanup-expired-brands",
  schedule: "0 0 * * *",
}
3

Execute Workflows

Run workflows from scheduled jobs:
import { Modules } from "@medusajs/framework/utils"
import { IWorkflowEngineService } from "@medusajs/framework/types"

export default async function generateDailyReport(
  container: MedusaContainer
) {
  const workflowEngine = container.resolve<IWorkflowEngineService>(
    Modules.WORKFLOW_ENGINE
  )

  await workflowEngine.run("generate-sales-report", {
    input: {
      date: new Date().toISOString(),
    },
  })
}

export const config = {
  name: "generate-daily-report",
  schedule: "0 6 * * *", // Run daily at 6am
}

Cron Schedule Syntax

Cron expressions define when jobs run:
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday = 0)
│ │ │ │ │
* * * * *

Common Examples

// Every minute
schedule: "* * * * *"

// Every hour at minute 0
schedule: "0 * * * *"

// Every day at midnight
schedule: "0 0 * * *"

// Every day at 6am
schedule: "0 6 * * *"

// Every Monday at 9am
schedule: "0 9 * * 1"

// Every 15 minutes
schedule: "*/15 * * * *"

// First day of month at midnight
schedule: "0 0 1 * *"

// Every weekday at 8am
schedule: "0 8 * * 1-5"

Real-World Examples

Cleanup Inactive Data

src/jobs/cleanup-inactive-brands.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { BRAND_MODULE } from "../modules/brand"
import { IBrandModuleService } from "../modules/brand/types"

export default async function cleanupInactiveBrands(
  container: MedusaContainer
) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const brandService = container.resolve<IBrandModuleService>(BRAND_MODULE)

  logger.info("Starting cleanup of inactive brands")

  try {
    // Find brands inactive for 6 months
    const sixMonthsAgo = new Date()
    sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)

    const inactiveBrands = await brandService.listBrands({
      is_active: false,
      updated_at: {
        $lt: sixMonthsAgo,
      },
    })

    if (inactiveBrands.length === 0) {
      logger.info("No inactive brands to clean up")
      return
    }

    const brandIds = inactiveBrands.map((brand) => brand.id)
    await brandService.softDeleteBrands(brandIds)

    logger.info(`Soft deleted ${inactiveBrands.length} inactive brands`)
  } catch (error) {
    logger.error("Failed to cleanup inactive brands", error)
  }
}

export const config = {
  name: "cleanup-inactive-brands",
  schedule: "0 2 * * *", // 2am daily
}

Sync External Data

src/jobs/sync-external-inventory.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { IInventoryService } from "@medusajs/framework/types"

export default async function syncExternalInventory(
  container: MedusaContainer
) {
  const logger = container.resolve("logger")
  const inventoryService = container.resolve<IInventoryService>(
    Modules.INVENTORY
  )

  logger.info("Starting inventory sync from external system")

  try {
    // Fetch from external API
    const response = await fetch("https://external-api.com/inventory")
    const externalInventory = await response.json()

    // Update inventory levels
    for (const item of externalInventory) {
      await inventoryService.updateInventoryLevels([
        {
          inventory_item_id: item.sku,
          location_id: item.location,
          stocked_quantity: item.quantity,
        },
      ])
    }

    logger.info(`Synced ${externalInventory.length} inventory items`)
  } catch (error) {
    logger.error("Failed to sync external inventory", error)
  }
}

export const config = {
  name: "sync-external-inventory",
  schedule: "*/30 * * * *", // Every 30 minutes
}

Generate Reports

src/jobs/generate-weekly-report.ts
import { MedusaContainer } from "@medusajs/framework/types"
import {
  ContainerRegistrationKeys,
  Modules,
} from "@medusajs/framework/utils"
import { IWorkflowEngineService } from "@medusajs/framework/types"

export default async function generateWeeklyReport(
  container: MedusaContainer
) {
  const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
  const workflowEngine = container.resolve<IWorkflowEngineService>(
    Modules.WORKFLOW_ENGINE
  )

  logger.info("Generating weekly sales report")

  try {
    const endDate = new Date()
    const startDate = new Date()
    startDate.setDate(startDate.getDate() - 7)

    await workflowEngine.run("generate-sales-report", {
      input: {
        start_date: startDate.toISOString(),
        end_date: endDate.toISOString(),
        recipients: ["[email protected]"],
      },
    })

    logger.info("Weekly sales report generated successfully")
  } catch (error) {
    logger.error("Failed to generate weekly report", error)
  }
}

export const config = {
  name: "generate-weekly-report",
  schedule: "0 8 * * 1", // Every Monday at 8am
}

Cache Warming

src/jobs/warm-product-cache.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { IProductModuleService } from "@medusajs/framework/types"

export default async function warmProductCache(
  container: MedusaContainer
) {
  const logger = container.resolve("logger")
  const productService = container.resolve<IProductModuleService>(
    Modules.PRODUCT
  )
  const cacheService = container.resolve("cache")

  logger.info("Warming product cache")

  try {
    // Fetch top products
    const products = await productService.listProducts(
      { status: "published" },
      {
        take: 100,
        relations: ["variants", "images"],
      }
    )

    // Cache each product
    for (const product of products) {
      const cacheKey = `product:${product.id}`
      await cacheService.set(cacheKey, JSON.stringify(product), 3600)
    }

    logger.info(`Cached ${products.length} products`)
  } catch (error) {
    logger.error("Failed to warm product cache", error)
  }
}

export const config = {
  name: "warm-product-cache",
  schedule: "0 */6 * * *", // Every 6 hours
}

Job Configuration Options

export const config = {
  // Required: Unique job name
  name: "my-job",

  // Required: Cron schedule
  schedule: "0 0 * * *",

  // Optional: Job metadata
  data: {
    custom: "metadata",
  },
}

Error Handling

Always handle errors in scheduled jobs:
export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve("logger")

  try {
    // Job logic
  } catch (error) {
    logger.error("Job failed", {
      job: "my-job",
      error: error.message,
      stack: error.stack,
    })

    // Optionally: Send alert, notify team, etc.
  }
}

Monitoring and Logging

Use structured logging for monitoring:
export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve("logger")

  const startTime = Date.now()
  logger.info("Job started", { job: "my-job" })

  try {
    // Job logic
    const recordsProcessed = 100

    logger.info("Job completed", {
      job: "my-job",
      duration: Date.now() - startTime,
      recordsProcessed,
    })
  } catch (error) {
    logger.error("Job failed", {
      job: "my-job",
      duration: Date.now() - startTime,
      error: error.message,
    })
  }
}

Best Practices

  • Use try-catch blocks to handle errors gracefully
  • Log job start, completion, and errors with structured data
  • Keep jobs idempotent (safe to run multiple times)
  • Use workflows for complex operations instead of putting logic in jobs
  • Set appropriate schedules - don’t run jobs more frequently than needed
  • Consider timezone implications of cron schedules
  • Test job logic independently before deploying
  • Monitor job execution and set up alerts for failures
  • Use soft deletes when cleaning up data
  • Be cautious with bulk operations - consider batching

Testing Scheduled Jobs

Test your job logic independently:
// In a test file
import cleanupExpiredBrands from "./cleanup-expired-brands"
import { createContainer } from "../test-utils"

describe("cleanupExpiredBrands", () => {
  it("should delete expired brands", async () => {
    const container = createContainer()
    await cleanupExpiredBrands(container)
    // Assert expected behavior
  })
})

Disabling Jobs

To temporarily disable a job, you can:
  1. Comment out the job file
  2. Rename it to not match the job file pattern
  3. Add a condition to skip execution:
export default async function myJob(container: MedusaContainer) {
  const config = container.resolve("configModule")

  if (config.featureFlags.disableMyJob) {
    return
  }

  // Job logic
}

Next Steps

Event Subscribers

React to events instead of schedules

Create Workflows

Build complex job logic as workflows

Build docs developers (and LLMs) love