Run Scheduled Tasks Without a Cron Service

·5 min read·Updated
cronschedulingbotsworkerspythonnodejshow-to

If you need to run scheduled tasks — a daily digest, an hourly sync, a weekly cleanup — and you have a process running continuously, the simplest answer is to schedule the work inside that process rather than paying for a separate cron service. A library like APScheduler or node-cron turns the worker into its own scheduler in about five lines. Three other things reliably break in-process schedules, and all three are avoidable.

Two ways to schedule work

A separate cron service runs your script on a schedule in a fresh container, then exits. It's isolated, it's visible in a dashboard, and each job is a billable unit — Render, for instance, applies a minimum monthly charge of $1 per cron job service, prorated by the second, with a 12-hour cap on any single run (Render docs).

An in-process scheduler runs inside the app you already have. No new container, no new bill, and the task shares your existing database connections, config, and code.

Which is right depends on one question: do you already have a process running?

Separate cron service In-process scheduler
Extra cost Per job None — reuses the running app
Cold start Yes, every run No
Shares your app's state and config No Yes
Survives your app crashing Yes No — schedule dies with the process
Isolated failure Yes No — a bad task can take the app down

For a Discord bot posting a daily message, or a worker polling an API every ten minutes, in-process wins easily. For a heavy nightly batch that you'd rather not run inside your live app, a separate job is genuinely better — and that's a real gap here, so be honest with yourself about which one you have.

Doing it in your existing app

Python, alongside a bot's own startup:

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = AsyncIOScheduler(timezone="UTC")

@scheduler.scheduled_job(CronTrigger(hour=9, minute=0))
async def daily_digest():
    await post_digest()

scheduler.start()

Node, with node-cron:

import cron from 'node-cron';

cron.schedule('0 9 * * *', async () => {
  await postDigest();
}, { timezone: 'UTC' });

Both run in the same process as your bot, so postDigest() already has your client, your database handle, and your config. Nothing new to deploy.

The three things that break it

1. The container's clock is UTC

Your container isn't in your timezone. 0 9 * * * fires at 09:00 UTC, which is not 9am where you live, and the gap changes twice a year when daylight saving shifts.

Always set the timezone explicitly, as both examples above do. Pick UTC and convert in your head, or name a real zone (Europe/Kyiv, America/New_York) so the library handles DST for you. What you must not do is leave it implicit and assume it matches your laptop.

2. A restart resets the schedule

An in-process scheduler holds its state in memory. When the container is recreated — a redeploy, a config change, a crash — the schedule starts over from that moment. A job due at 09:00 that restarts at 09:01 doesn't fire; nothing queued it.

If a missed run matters, record the last successful run and check on startup:

last = read_last_run()          # from a file on a volume
if last is None or last.date() < today():
    await daily_digest()        # catch up once, then let the scheduler take over

Store that marker on a volume, not next to your code — anything outside a volume is discarded when the container is recreated, which is exactly the event you're trying to detect. Same reason a bot's database belongs on a volume.

3. Slow tasks overlap themselves

A job scheduled every five minutes that sometimes takes six will start again before the previous run finishes. Two copies then race — double-posting, duplicate rows, or two writers on a SQLite file that allows one.

Guard it:

running = False

async def sync():
    global running
    if running:
        return          # previous run still going; skip this tick
    running = True
    try:
        await do_work()
    finally:
        running = False

APScheduler has max_instances=1 for this, and most schedulers have an equivalent. Set it deliberately rather than discovering the problem from duplicated output.

Wrap the task so it can't take the app down

An unhandled exception inside a scheduled callback can kill the process — and with it your bot. The platform will restart it, but you'd rather a failed digest didn't disconnect you from Discord for ten seconds.

async def daily_digest():
    try:
        await post_digest()
    except Exception:
        logger.exception("daily digest failed")

Catch, log, continue. The log line is what tells you it failed at all, since a scheduled task has nobody watching it in real time. If the process does die, you'll see it in the logs as an exit code — worth knowing how to read.

When you actually want a separate job

Be honest about the cases in-process handles badly:

  • Long batch work that would compete with your bot for a small memory quota.
  • Tasks that must run even when the app is down — by definition, an in-process scheduler can't.
  • One-off backfills you want to run once, watch, and never keep.

For the last one, you don't need a scheduler at all: open the app's shell and run the script by hand. Shell access is included free.

FAQ

Does JustRunMy.App have cron jobs?

No. There's no scheduled-job feature. Run the schedule inside your long-running app with a scheduling library, or trigger it externally.

How do I run a daily task in a Discord bot?

Add APScheduler (Python) or node-cron (Node) to the bot process and register a cron trigger. It shares the bot's client and config, so there's nothing extra to deploy.

Why does my scheduled task fire at the wrong time?

The container's clock is UTC. Set the timezone explicitly in your scheduler rather than relying on the default.

What happens to my schedule when the app restarts?

It resets — an in-process scheduler keeps its state in memory. Store a "last run" marker on a volume and catch up at startup if a missed run matters.

How do I stop a slow job overlapping itself?

Limit it to one concurrent instance (max_instances=1 in APScheduler) or guard it with a simple in-flight flag.

Can I run a one-off script without scheduling it?

Yes — use the app's shell and run it directly. That's usually the right tool for a backfill or a migration.

Wrapping up

"How do I run a cron job?" is usually the wrong question for a process that's already running. You don't need a scheduler service — you need a schedule, and the app you already pay for can hold one. Set the timezone, decide what a missed run means, stop tasks overlapping, and wrap the callback so a failure stays a failure instead of an outage.