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