Hybrid Scheduling Experiment

Exploring how Cloudflare Workers Cron Triggers can complement Modal's scheduled jobs, overcoming free tier limitations while maintaining cost efficiency.

Abstract

Modal's free tier limits users to 5 scheduled jobs. For systems requiring multiple background tasks—analytics, syncs, health checks, cleanups—this constraint forces premature platform commitment or awkward workarounds. This experiment proposes a hybrid architecture: Cloudflare Workers Cron Triggers handle scheduling (unlimited on paid plans, generous free tier), while Modal handles compute (pay-per-use, no idle costs). The result: unlimited schedules with Modal's powerful compute model, at minimal additional cost.

1. The Problem: Modal's Cron Limitations

Modal provides excellent serverless compute for Python workloads. Its @modal.cron() decorator makes scheduled jobs trivial:

# Modal's native scheduling - simple but limited

@app.function(schedule=modal.Cron("0 6 * * *"))

def daily_analytics_sync():

"""Runs every day at 6am UTC."""

process_analytics()

The limitation: Modal's free tier allows only 5 scheduled jobs. For a typical production system, you might need:

  • Daily analytics sync
  • Hourly health checks
  • Weekly reports
  • Nightly database cleanup
  • Every-5-minute monitoring
  • Bi-hourly data refresh

That's already 6 jobs—one over the limit. Adding more (backup jobs, notification digests, cache warming) quickly exceeds what the free tier allows.

2. The Insight: Separate Scheduling from Compute

Modal's scheduled jobs couple two concerns:

Scheduling

"Run this function at 6am daily"

Lightweight—just tracking time and triggering

Compute

"Execute this Python code with these dependencies"

Heavyweight—containers, GPUs, memory, etc.

The hybrid approach: Use Cloudflare Workers for scheduling (what they're good at), and Modal for compute (what it's good at).

Cloudflare Workers Cron Triggers: Unlimited on paid plans ($5/month base).

Even on the free tier, you get enough requests to trigger Modal functions hundreds of times per day.

3. Hybrid Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    Cloudflare Workers                           │
│                    (Cron Triggers)                              │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐             │
│  │ 0 6 * * *   │  │ 0 * * * *   │  │ */5 * * * * │  ...        │
│  │ daily sync  │  │ hourly      │  │ monitoring  │             │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘             │
│         │                │                │                     │
│         └────────────────┼────────────────┘                     │
│                          │                                      │
│                          ▼                                      │
│              ┌───────────────────────┐                          │
│              │  HTTP POST to Modal   │                          │
│              │  webhook endpoint     │                          │
│              └───────────────────────┘                          │
└─────────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                         Modal                                    │
│                    (Compute Platform)                            │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  @app.function()                                         │   │
│  │  @modal.web_endpoint(method="POST")                      │   │
│  │  def trigger_job(request: Request):                      │   │
│  │      job_name = request.json()["job"]                    │   │
│  │      dispatch_to_handler(job_name)                       │   │
│  └─────────────────────────────────────────────────────────┘   │
│                          │                                      │
│         ┌────────────────┼────────────────┐                     │
│         ▼                ▼                ▼                     │
│  ┌───────────┐    ┌───────────┐    ┌───────────┐               │
│  │ analytics │    │ health    │    │ cleanup   │   ...         │
│  │ sync      │    │ check     │    │ routine   │               │
│  └───────────┘    └───────────┘    └───────────┘               │
└─────────────────────────────────────────────────────────────────┘

3.1 Cloudflare Worker: The Scheduler

// wrangler.toml

name = "modal-scheduler"

main = "src/index.ts"

compatibility_date = "2024-01-01"

 

[triggers]

crons = [

"0 6 * * *", # daily-analytics-sync

"0 * * * *", # hourly-health-check

"0 9 * * 1", # weekly-report

"*/5 * * * *", # monitoring

"0 */2 * * *", # bi-hourly-sync

"0 3 * * *", # nightly-cleanup

]

// src/index.ts

interface Env {

MODAL_WEBHOOK_URL: string;

MODAL_AUTH_TOKEN: string;

}

 

// Map cron expressions to job names

const CRON_TO_JOB: Record<string, string> = {

"0 6 * * *": "daily-analytics-sync",

"0 * * * *": "hourly-health-check",

"0 9 * * 1": "weekly-report",

"*/5 * * * *": "monitoring",

"0 */2 * * *": "bi-hourly-sync",

"0 3 * * *": "nightly-cleanup",

};

 

export default {

async scheduled(

event: ScheduledEvent,

env: Env,

ctx: ExecutionContext

) {

const jobName = CRON_TO_JOB[event.cron] || "unknown";

 

const response = await fetch(env.MODAL_WEBHOOK_URL, {

method: "POST",

headers: {

"Content-Type": "application/json",

"Authorization": `Bearer ${env.MODAL_AUTH_TOKEN}`,

},

body: JSON.stringify({

job: jobName,

triggered_at: new Date().toISOString(),

cron: event.cron,

}),

});

 

console.log(`Triggered ${jobName}: ${response.status}`);

},

};

3.2 Modal: The Compute Engine

# modal_jobs.py

import modal

from fastapi import Request, HTTPException

 

app = modal.App("scheduled-jobs")

 

# Job handlers

@app.function()

def daily_analytics_sync():

"""Process daily analytics."""

print("Running daily analytics sync...")

# Your analytics logic here

 

@app.function()

def hourly_health_check():

"""Check system health."""

print("Running health check...")

# Your health check logic here

 

@app.function()

def weekly_report():

"""Generate weekly report."""

print("Generating weekly report...")

# Your report logic here

 

# ... more job handlers ...

 

# Dispatcher endpoint

JOB_HANDLERS = {

"daily-analytics-sync": daily_analytics_sync,

"hourly-health-check": hourly_health_check,

"weekly-report": weekly_report,

# ... more mappings ...

}

 

@app.function()

@modal.web_endpoint(method="POST")

async def trigger_job(request: Request):

"""Webhook endpoint for Cloudflare triggers."""

data = await request.json()

job_name = data.get("job")

 

if job_name not in JOB_HANDLERS:

raise HTTPException(status_code=404, detail=f"Unknown job: {job_name}")

 

# Spawn the job asynchronously

JOB_HANDLERS[job_name].spawn()

 

return {"status": "triggered", "job": job_name}

4. Benefits of the Hybrid Approach

Unlimited Schedules

Cloudflare Workers paid plan ($5/month) includes unlimited cron triggers. Add as many schedules as your system needs without hitting Modal's limits.

Pay-Per-Use Compute

Modal's pricing remains pay-per-use. You only pay for actual compute time, not for scheduling infrastructure.

Reliable Triggers

Cloudflare's edge network ensures triggers fire reliably, globally, with sub-second latency. No cold starts for the trigger itself.

Centralized Scheduling

All schedules defined in one wrangler.toml. Easy to see, modify, and version control all your cron jobs.

Observability

Cloudflare logs every trigger. Modal logs every execution. Combined, you get end-to-end visibility into scheduled job behavior.

Separation of Concerns

Scheduling is scheduling. Compute is compute. Each platform does what it's best at—Zuhandenheit applied to infrastructure.

5. Cost Analysis

Comparing the cost of running 10 scheduled jobs under different approaches:

ApproachScheduling CostCompute CostLimitation
Modal Only (Free)$0$0 (free tier)Max 5 cron jobs
Modal Only (Paid)Included$0.000016/GB-sUnlimited (but paying for compute)
Hybrid (Cloudflare + Modal)$5/month (Workers paid)$0.000016/GB-sUnlimited schedules + pay-per-use compute

Key insight: The $5/month Cloudflare Workers paid plan pays for itself if you need more than 5 scheduled jobs and want to stay on Modal's free compute tier (or minimize compute costs).

6. Implementation Notes

6.1 Authentication

Protect your Modal webhook endpoint. Options:

  • Shared secret in Authorization header (simplest)
  • Cloudflare Access service tokens (enterprise)
  • HMAC signature verification (most secure)

6.2 Error Handling

Cloudflare cron triggers don't retry on failure by default. Implement:

  • Queue failed jobs to Cloudflare Queues for retry
  • Log failures to D1 for visibility
  • Alert on repeated failures via Workers AI or external service

6.3 Idempotency

Design jobs to be idempotent. If a trigger fires twice (edge case), the job should produce the same result without side effects.

# Include execution ID for idempotency checks

body: JSON.stringify({

job: jobName,

execution_id: crypto.randomUUID(),

triggered_at: new Date().toISOString(),

}),

7. Example Schedule Configuration

A typical production system might have the following schedules:

Daily analytics sync

Every day at 6am UTC

0 6 * * *

Hourly health check

Every hour on the hour

0 * * * *

Weekly report

Every Monday at 9am UTC

0 9 * * 1

Every 5 minutes

High-frequency monitoring

*/5 * * * *

Bi-hourly sync

Every 2 hours

0 */2 * * *

Nightly cleanup

Every day at 3am UTC

0 3 * * *

8. Conclusion

The hybrid Modal + Cloudflare scheduling approach embodies the Subtractive Triad:

  • DRY: Scheduling logic lives in one place (wrangler.toml), not scattered across Modal decorators
  • Rams: Each platform does only what it's good at—no waste, no overlap
  • Heidegger: The infrastructure recedes; you focus on the jobs themselves, not scheduling limitations

For systems that need more than 5 scheduled jobs, this pattern offers a pragmatic path forward: unlimited schedules, pay-per-use compute, and clear separation of concerns.

Next Steps:

  • Implement a proof-of-concept with 10+ scheduled jobs
  • Measure end-to-end latency (trigger to execution)
  • Build retry mechanism using Cloudflare Queues
  • Add observability dashboard with trigger/execution correlation

Part of the experiments collection. View related: Modal Cron Documentation | Cloudflare Cron Triggers