Skip to main content

Overview

This tutorial shows you how to build scheduled tasks using Motia’s cron triggers. You’ll create a system that runs periodic maintenance jobs, generates reports, and cleans up old data automatically. What you’ll learn:
  • Using cron triggers for scheduled execution
  • Cron expression syntax
  • Periodic data processing
  • Scheduled maintenance tasks
  • Cleanup jobs
  • Report generation

Prerequisites

Before starting, make sure you have:
  • Node.js version 19 or higher
  • Completed the Hello World tutorial
  • Basic understanding of cron expressions

Use Case: Automated Maintenance System

We’ll build a system that:
  1. Cleans up expired sessions every hour
  2. Generates daily reports at midnight
  3. Monitors system health every 5 minutes
  4. Archives old data weekly
  5. Sends summary notifications

Project Setup

1

Create project

mkdir scheduled-tasks
cd scheduled-tasks
npm init -y
2

Install dependencies

npm install motia zod
npm install -D typescript @types/node
Update package.json:
{
  "type": "module",
  "scripts": {
    "dev": "iii",
    "build": "motia build"
  }
}
3

Create structure

mkdir -p steps/maintenance

Cron Expression Quick Reference

* * * * * * *
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ Year (optional)
│ │ │ │ │ └─── Day of Week (0-7, 0 and 7 = Sunday)
│ │ │ │ └───── Month (1-12)
│ │ │ └─────── Day of Month (1-31)
│ │ └───────── Hour (0-23)
│ └─────────── Minute (0-59)
└───────────── Second (0-59)
Common patterns:
  • */5 * * * * * - Every 5 seconds
  • 0 */5 * * * * - Every 5 minutes
  • 0 0 * * * * - Every hour
  • 0 0 0 * * * - Daily at midnight
  • 0 0 0 * * 0 - Weekly on Sunday at midnight
  • 0 0 0 1 * * - Monthly on the 1st at midnight

Building the System

Step 1: Session Cleanup (Hourly)

Create steps/maintenance/cleanup-sessions.step.ts:
steps/maintenance/cleanup-sessions.step.ts
import type { Handlers, StepConfig } from 'motia'

export const config = {
  name: 'CleanupSessions',
  description: 'Removes expired sessions every hour',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'cron',
      expression: '0 0 * * * *', // Every hour at minute 0
    },
  ],
  enqueues: ['send-cleanup-report'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { logger, state, enqueue }) => {
  const startTime = Date.now()
  logger.info('Starting session cleanup')

  try {
    // Get all sessions
    const sessions = await state.list('sessions')
    const now = Date.now()
    const expirationTime = 24 * 60 * 60 * 1000 // 24 hours

    let expiredCount = 0
    let activeCount = 0

    for (const session of sessions) {
      const sessionAge = now - new Date(session.createdAt).getTime()

      if (sessionAge > expirationTime) {
        await state.delete('sessions', session.id)
        expiredCount++
        logger.debug('Deleted expired session', { sessionId: session.id })
      } else {
        activeCount++
      }
    }

    const duration = Date.now() - startTime

    logger.info('Session cleanup completed', {
      expiredCount,
      activeCount,
      totalScanned: sessions.length,
      durationMs: duration,
    })

    // Enqueue report
    await enqueue({
      topic: 'send-cleanup-report',
      data: {
        jobType: 'session-cleanup',
        expiredCount,
        activeCount,
        duration,
        timestamp: new Date().toISOString(),
      },
    })
  } catch (error) {
    logger.error('Session cleanup failed', { error })
    throw error
  }
}
Key concepts:
  • Cron trigger: Automatically executes on schedule
  • No input: Cron-triggered Steps don’t receive input data
  • Enqueue reports: Sends results to notification system
  • Error handling: Logs failures for monitoring

Step 2: Daily Report Generation

Create steps/maintenance/generate-daily-report.step.ts:
steps/maintenance/generate-daily-report.step.ts
import type { Handlers, StepConfig } from 'motia'

export const config = {
  name: 'GenerateDailyReport',
  description: 'Generates daily analytics report at midnight',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'cron',
      expression: '0 0 0 * * *', // Daily at midnight
    },
  ],
  enqueues: ['send-daily-report'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { logger, state, enqueue }) => {
  logger.info('Generating daily report')

  const yesterday = new Date()
  yesterday.setDate(yesterday.getDate() - 1)
  yesterday.setHours(0, 0, 0, 0)

  const today = new Date()
  today.setHours(0, 0, 0, 0)

  // Gather metrics
  const orders = await state.list('orders')
  const users = await state.list('users')
  const sessions = await state.list('sessions')

  // Filter for yesterday
  const yesterdayOrders = orders.filter((order) => {
    const orderDate = new Date(order.createdAt)
    return orderDate >= yesterday && orderDate < today
  })

  const revenue = yesterdayOrders.reduce((sum, order) => sum + order.total, 0)

  const newUsers = users.filter((user) => {
    const userDate = new Date(user.createdAt)
    return userDate >= yesterday && userDate < today
  })

  const report = {
    date: yesterday.toISOString().split('T')[0],
    metrics: {
      totalOrders: yesterdayOrders.length,
      revenue,
      averageOrderValue: yesterdayOrders.length > 0 ? revenue / yesterdayOrders.length : 0,
      newUsers: newUsers.length,
      activeSessions: sessions.length,
    },
    generatedAt: new Date().toISOString(),
  }

  // Store report
  const reportId = `report-${yesterday.toISOString().split('T')[0]}`
  await state.set('reports', reportId, report)

  logger.info('Daily report generated', { reportId, metrics: report.metrics })

  // Send report notification
  await enqueue({
    topic: 'send-daily-report',
    data: {
      reportId,
      report,
    },
  })
}

Step 3: Health Monitor (Every 5 Minutes)

Create steps/maintenance/health-check.step.ts:
steps/maintenance/health-check.step.ts
import type { Handlers, StepConfig } from 'motia'

export const config = {
  name: 'HealthCheck',
  description: 'Monitors system health every 5 minutes',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'cron',
      expression: '0 */5 * * * *', // Every 5 minutes
    },
  ],
  enqueues: ['alert-on-health-issue'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { logger, state, enqueue }) => {
  logger.info('Running health check')

  const checks = {
    timestamp: new Date().toISOString(),
    status: 'healthy' as 'healthy' | 'degraded' | 'unhealthy',
    checks: {
      database: false,
      queues: false,
      memory: false,
    },
    issues: [] as string[],
  }

  // Check database (state operations)
  try {
    await state.get('health-check', 'ping')
    await state.set('health-check', 'ping', { timestamp: checks.timestamp })
    checks.checks.database = true
  } catch (error) {
    checks.issues.push('Database connectivity issue')
    logger.error('Database health check failed', { error })
  }

  // Check memory usage
  const memUsage = process.memoryUsage()
  const memUsageMB = memUsage.heapUsed / 1024 / 1024
  const memLimitMB = 512 // Example limit

  if (memUsageMB < memLimitMB * 0.8) {
    checks.checks.memory = true
  } else if (memUsageMB < memLimitMB) {
    checks.checks.memory = true
    checks.issues.push('Memory usage high (>80%)')
  } else {
    checks.issues.push('Memory usage critical (>100%)')
  }

  // Queue check - verify recent job processing
  checks.checks.queues = true // Simplified for example

  // Determine overall status
  const allChecksPass = Object.values(checks.checks).every((check) => check)
  checks.status = allChecksPass ? 'healthy' : checks.issues.length > 2 ? 'unhealthy' : 'degraded'

  // Store health status
  await state.set('health-status', 'current', checks)

  logger.info('Health check completed', {
    status: checks.status,
    issueCount: checks.issues.length,
  })

  // Alert if unhealthy
  if (checks.status !== 'healthy') {
    await enqueue({
      topic: 'alert-on-health-issue',
      data: {
        status: checks.status,
        issues: checks.issues,
        timestamp: checks.timestamp,
      },
    })
  }
}

Step 4: Weekly Data Archival

Create steps/maintenance/archive-old-data.step.ts:
steps/maintenance/archive-old-data.step.ts
import type { Handlers, StepConfig } from 'motia'

export const config = {
  name: 'ArchiveOldData',
  description: 'Archives data older than 30 days every Sunday',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'cron',
      expression: '0 0 2 * * 0', // Sunday at 2 AM
    },
  ],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { logger, state }) => {
  logger.info('Starting data archival')

  const cutoffDate = new Date()
  cutoffDate.setDate(cutoffDate.getDate() - 30)
  const cutoffTime = cutoffDate.getTime()

  let archivedCount = 0
  const archiveData: any[] = []

  // Archive old orders
  const orders = await state.list('orders')

  for (const order of orders) {
    const orderDate = new Date(order.createdAt).getTime()

    if (orderDate < cutoffTime && order.status === 'completed') {
      archiveData.push({
        type: 'order',
        data: order,
        archivedAt: new Date().toISOString(),
      })

      // Move to archive state bucket
      await state.set('archive', order.orderId, order)
      await state.delete('orders', order.orderId)

      archivedCount++
    }
  }

  // Store archive metadata
  const archiveId = `archive-${new Date().toISOString().split('T')[0]}`
  await state.set('archive-metadata', archiveId, {
    id: archiveId,
    recordCount: archivedCount,
    cutoffDate: cutoffDate.toISOString(),
    archivedAt: new Date().toISOString(),
  })

  logger.info('Data archival completed', {
    archivedCount,
    cutoffDate: cutoffDate.toISOString(),
  })
}

Step 5: Notification Handlers

Create steps/maintenance/send-reports.step.ts:
steps/maintenance/send-reports.step.ts
import { type Handlers, type StepConfig } from 'motia'
import { z } from 'zod'

const cleanupReportSchema = z.object({
  jobType: z.string(),
  expiredCount: z.number(),
  activeCount: z.number(),
  duration: z.number(),
  timestamp: z.string(),
})

const dailyReportSchema = z.object({
  reportId: z.string(),
  report: z.object({
    date: z.string(),
    metrics: z.any(),
    generatedAt: z.string(),
  }),
})

const healthAlertSchema = z.object({
  status: z.enum(['healthy', 'degraded', 'unhealthy']),
  issues: z.array(z.string()),
  timestamp: z.string(),
})

export const cleanupReportConfig = {
  name: 'SendCleanupReport',
  description: 'Sends cleanup job reports',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'queue',
      topic: 'send-cleanup-report',
      input: cleanupReportSchema,
    },
  ],
  enqueues: [],
} as const satisfies StepConfig

export const cleanupReportHandler: Handlers<typeof cleanupReportConfig> = async (
  input,
  { logger }
) => {
  logger.info('Sending cleanup report', input)
  console.log(`\n🧹 CLEANUP REPORT:`, JSON.stringify(input, null, 2), '\n')
}

export const dailyReportConfig = {
  name: 'SendDailyReport',
  description: 'Sends daily analytics reports',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'queue',
      topic: 'send-daily-report',
      input: dailyReportSchema,
    },
  ],
  enqueues: [],
} as const satisfies StepConfig

export const dailyReportHandler: Handlers<typeof dailyReportConfig> = async (
  input,
  { logger }
) => {
  logger.info('Sending daily report', { reportId: input.reportId })
  console.log(`\n📊 DAILY REPORT:`, JSON.stringify(input.report.metrics, null, 2), '\n')
}

export const healthAlertConfig = {
  name: 'AlertOnHealthIssue',
  description: 'Sends alerts for health issues',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'queue',
      topic: 'alert-on-health-issue',
      input: healthAlertSchema,
    },
  ],
  enqueues: [],
} as const satisfies StepConfig

export const healthAlertHandler: Handlers<typeof healthAlertConfig> = async (
  input,
  { logger }
) => {
  logger.warn('Health issue detected', input)
  console.log(`\n⚠️ HEALTH ALERT:`, JSON.stringify(input, null, 2), '\n')
}

Step 6: Manual Trigger API

Create steps/maintenance/trigger-job.step.ts:
steps/maintenance/trigger-job.step.ts
import { type Handlers, type StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'TriggerMaintenanceJob',
  description: 'Manually trigger maintenance jobs',
  flows: ['maintenance'],
  triggers: [
    {
      type: 'http',
      method: 'POST',
      path: '/maintenance/trigger/:jobType',
      responseSchema: {
        200: z.object({ message: z.string(), jobType: z.string() }),
        400: z.object({ error: z.string() }),
      },
    },
  ],
  enqueues: [
    'send-cleanup-report',
    'send-daily-report',
    'alert-on-health-issue',
  ],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  { request },
  { logger, enqueue }
) => {
  const { jobType } = request.pathParams || {}

  logger.info('Manual job trigger requested', { jobType })

  // Map job types to their respective enqueue topics
  const validJobs = ['cleanup', 'report', 'health-check']

  if (!validJobs.includes(jobType || '')) {
    return {
      status: 400,
      body: { error: `Invalid job type. Valid types: ${validJobs.join(', ')}` },
    }
  }

  // For demonstration, trigger a mock job
  await enqueue({
    topic: 'send-cleanup-report',
    data: {
      jobType,
      expiredCount: 0,
      activeCount: 0,
      duration: 0,
      timestamp: new Date().toISOString(),
    },
  })

  return {
    status: 200,
    body: {
      message: `${jobType} job triggered successfully`,
      jobType,
    },
  }
}

Running the Application

1

Start the server

npm run dev
The cron jobs will start running according to their schedules.
2

Watch scheduled execution

Monitor the logs to see jobs executing:
[HealthCheck] Running health check
[HealthCheck] Health check completed: healthy
3

Manually trigger jobs

For testing, manually trigger a job:
curl -X POST http://localhost:3000/maintenance/trigger/cleanup
Response:
{
  "message": "cleanup job triggered successfully",
  "jobType": "cleanup"
}
4

Test with faster schedules

For development, use faster cron expressions:
// Run every 10 seconds instead of hourly
expression: '*/10 * * * * *'

Testing Cron Jobs

Create Test Data

Create scripts/seed-data.ts:
scripts/seed-data.ts
// Create test sessions with various ages
const sessions = [
  { id: 'session-1', createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString() },
  { id: 'session-2', createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() },
  { id: 'session-3', createdAt: new Date().toISOString() },
]

for (const session of sessions) {
  await state.set('sessions', session.id, session)
}

Development Tips

  1. Use shorter intervals: Test with seconds instead of hours
  2. Add logging: Include detailed logs to track execution
  3. Manual triggers: Create HTTP endpoints to run jobs on demand
  4. Monitor execution: Watch logs and state changes

Production Considerations

Important production settings:
  • Timezone: Cron expressions use UTC by default
  • Idempotency: Ensure jobs can safely run multiple times
  • Locking: Prevent concurrent execution if needed
  • Monitoring: Track job success/failure rates
  • Alerting: Get notified of job failures

Add Execution Locking

export const handler: Handlers<typeof config> = async (_, { logger, state }) => {
  const lockKey = 'cleanup-job-lock'
  const lock = await state.get('locks', lockKey)

  if (lock?.locked) {
    logger.warn('Job already running, skipping')
    return
  }

  await state.set('locks', lockKey, { locked: true, startedAt: new Date().toISOString() })

  try {
    // Perform cleanup
  } finally {
    await state.delete('locks', lockKey)
  }
}

Common Schedules

TaskExpressionDescription
Every minute0 * * * * *Testing/monitoring
Every 5 minutes0 */5 * * * *Health checks
Hourly0 0 * * * *Cleanup jobs
Daily at 2 AM0 0 2 * * *Reports
Weekly (Sunday)0 0 0 * * 0Archival
Monthly (1st)0 0 0 1 * *Billing
Weekdays at 9 AM0 0 9 * * 1-5Business hours

Next Steps

Background Jobs

Handle failures in scheduled jobs

Observability

Track job execution and performance

State Management

Advanced state operations

Production Deploy

Deploy your scheduled tasks

Build docs developers (and LLMs) love