From Cron Jobs to Event-Driven: Migrating Scheduled Tasks to Serverless Functions
If you have a crontab on a box that nobody wants to reboot, the migration path is usually: move the schedule to a managed scheduler (EventBridge Scheduler, Cloud Scheduler, a Vercel/Cloudflare cron trigger), move the script into a function, and — only where it actually helps — replace the fixed schedule with an event that fires when the work is genuinely ready. The first two steps are almost always worth it. The third is where teams either get a real reliability win or quietly make their system harder to reason about. I've done this migration on a few systems, from a single "nightly report" job to a fan-out pipeline processing uploaded files. Here's what actually mattered. Why move scheduled tasks off a server at all? A cron job on a VM has three failure modes that have nothing to do with your code: the box dies and the schedule dies with it, two overlapping runs stomp on each other because cron doesn't care that the last run is still going, and nobody notices a silent failure until a report is missing. You end up building a babysitter — a health check, a lock file, an alert — around a one-line schedule. Managed serverless schedulers hand you most of that for free. The schedule lives in the platform's control plane, not on a machine you patch. Invocations are logged and metered whether they succeed or fail. Retries and dead-letter queues are configuration, not code you maintain. In exchange, you accept execution limits (time, memory, package size) and cold starts, and you give up the comfort of SSHing in to see what happened. The honest tradeoff: you trade a server you have to keep alive for a platform whose limits you have to design around. What's the difference between "scheduled serverless" and "event-driven"? These get conflated, and the distinction drives the whole migration. Scheduled serverless is your cron job with a better host. A managed scheduler fires your function every 15 minutes / at 2am / on the first of the month. The trigger is still time. You've improved reliability and ops, but the logic is unchanged: "wake up on a clock, go check if there's work." Event-driven replaces the clock with a fact. Instead of polling every 15 minutes for new uploads, an object-created event invokes the function the moment a file lands. Instead of a nightly job that scans for orders to fulfill, an "order placed" event kicks off fulfillment immediately. Dimension Scheduled (cron-style) Event-driven Trigger Time (fixed interval) A fact occurred (message, upload, state change) Latency Up to one full interval Near-immediate Wasted invocations Runs even when there's nothing to do Runs only when there's work Idempotency need Moderate High — events can arrive twice or out of order Best for Reports, cleanup, reconciliation, digests Reacting to user or system actions Debuggability Easy — deterministic timeline Harder — distributed, async traces The mistake is treating event-driven as strictly superior. A monthly billing reconciliation is a time-based fact; forcing it into an event model buys you nothing. Convert to events when the real trigger was never the clock — you were just polling on a timer because that was the only tool you had. The takeaway: migrate the host for every job; migrate the trigger model only for jobs where time was a proxy for an event. How do you actually move a cron job to a managed scheduler? Take a nightly cleanup job. On a server it might be: 0 3 * * * /usr/bin/python3 /opt/app/cleanup_stale_sessions.py The function is the same script minus the schedule. On AWS, EventBridge Scheduler owns the timing and invokes a Lambda: # handler.py — the body is your old script, wrapped in a handler import os import psycopg def handler(event, context): cutoff_days = int(os.environ.get("CUTOFF_DAYS", "30")) with psycopg.connect(os.environ["DATABASE_URL"]) as conn: with conn.cursor() as cur: cur.execute( "DELETE FROM sessions WHERE last_seen < now() - (%s || ' days')::interval", (cutoff_days,), ) deleted = cur.rowcount conn.commit() # Structured output shows up in your logs — this replaces "did it run?" guesswork return {"deleted": deleted} The cron expression moves into the scheduler's configuration rather than a file on disk. With EventBridge Scheduler you'd set a cron(0 3 * * ? *) or a rate expression, point it at the function's ARN, and attach an IAM role. The equivalents elsewhere: Google Cloud: Cloud Scheduler → Pub/Sub or HTTP → Cloud Functions / Cloud Run. Azure: a Functions timer trigger (the schedule lives in the function's binding). Vercel: Cron Jobs defined in vercel.json, hitting an API route. Cloudflare: Workers Cron Triggers in wrangler.toml. GitHub Actions: a schedule: trigger — fine for low-stakes maintenance, but note GitHub explicitly warns scheduled workflows can be delayed under load, so don't use it for anything time-critical. Two things bite people here. First, timezones: most of these schedulers run in UTC by default, and "3am" quietly becomes a different hour for your users. EventBridge Scheduler lets you set a timezone; some others don't, and you do the offset math yourself. Second, execution limits: a cleanup that ran for 20 minutes on a VM will hit a function timeout. That job needs to be chunked, not lifted as-is. The takeaway: the code barely changes — the schedule, the timezone, and the runtime limits are what you actually migrate. When should you convert the schedule into an event? Convert when your scheduled job is really a poll in disguise. The tell is a job that starts by asking "is there anything to do?" — scanning a table for status = 'pending', listing a bucket for new files, checking a queue depth. Take a job that polls for uploaded files every 15 minutes. Event-driven, the storage service emits an object-created event and invokes the function per file: # S3 -> Lambda. One invocation per uploaded object, at upload time. import urllib.parse def handler(event, context): for record in event["Records"]: bucket = record["s3"]["bucket"]["name"] key = urllib.parse.unquote_plus(record["s3"]["object"]["key"]) process_file(bucket, key) # your existing logic, now per-file return {"processed": len(event["Records"])} You've gone from "up to 15 minutes late, and a big scan every cycle" to "processed on arrival, one invocation per file." But you've taken on new obligations. Events can be delivered more than once — most event sources are at-least-once, so process_file must be idempotent (a processed-keys table, or a natural unique constraint). Ordering isn't guaranteed unless you opt into it. And a poison event that always fails will retry forever unless you configure a dead-letter queue. None of these existed in the cron version, where a single sequential scan sidestepped all of it. There's also a debuggability tax. A cron job has one clean timeline you can read top to bottom. An event-driven flow is distributed and asynchronous; understanding "why didn't this file get processed" means correlating traces across services. Budget for structured logging and a request/correlation ID from day one, or you'll be blind. The takeaway: events buy you latency and eliminate wasted scans, but you pay for it in idempotency, dead-letter handling, and harder debugging — make that trade deliberately. What does this cost, and when is a VM still cheaper? Serverless pricing is per-invocation plus compute-time, which is close to free for jobs that run occasionally. A function firing a few thousand times a month with modest memory typically lands within, or just above, a provider's free allowance — check current pricing, as the free tiers and per-request rates shift. That's a genuine win over paying for a VM to sit idle 23 hours a day. The economics flip in two cases. High-frequency, always-busy workloads — a function invoked continuously — can cost more than a right-sized always-on container, because you're paying a premium for elasticity you're not using. And long-running jobs that fight the timeout are a signal you've outgrown functions; a batch/container service (Cloud Run jobs, AWS Batch, ECS scheduled tasks) is the better home. As of mid-2026 the per-request and per-GB-second rates across the major clouds are low enough that for genuinely intermittent tasks, the build-vs-buy math almost always favors managed serverless over babysitting a server. The takeaway: serverless wins decisively for spiky, intermittent work; a container or VM wins for sustained high throughput or jobs that can't fit the time limit. Bottom line Move every scheduled task off self-managed servers onto a managed scheduler — you get reliability, logging, and retries without maintaining a babysitter, and for intermittent jobs it's usually cheaper too. Keep the time trigger for anything genuinely periodic: reports, reconciliation, digests, cleanup on a real calendar cadence. Convert to event triggers only for jobs that were secretly polling — reacting to uploads, user actions, or state changes — and when you do, commit to idempotency, a dead-letter queue, and correlation IDs up front. If a job runs continuously or can't finish inside the function timeout, that's your signal to reach for a container service instead of forcing it into a function. Related reading Zapier vs Make vs n8n: When Paying Per Task Stops Making Sense Automate Your Code Reviews with an LLM Without Annoying Your Team Postman vs Bruno vs Hoppscotch: Does Your API Client Really Need a Cloud Account?
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to