Choosing cron vs APScheduler for Single-Server Dashboards

Part of the Choosing a Scheduler: Celery vs APScheduler vs cron guide.

Operative rule: on one box the decision is whether the rebuild needs the running app’s context — if it needs live config, a warm connection pool, or app_context, use APScheduler; if it is a self-contained script, use system cron for its process isolation.

How the Two Approaches Differ

System cron is an OS-level daemon that reads a crontab and, at each due minute, forks a fresh, isolated process to run a command. It knows nothing about your application: it launches a shell, runs your script, and reaps it. That isolation is the whole point — the rebuild has its own memory, its own file descriptors, and its own crash domain. If a tile build leaks memory or wedges, cron’s process dies alone and the web server, running in a completely separate process, never notices. The cost is that the script must bootstrap everything itself: parse config, open a database connection, import the world. There is no live application state to lean on, and no shared cache.

APScheduler runs inside your dashboard’s Python process, as covered in scheduling map rebuilds with APScheduler in Flask. The job function executes in the same interpreter as the request handlers, so it can reuse the app’s config object, an already-warm connection pool, in-memory caches, and Flask’s app_context directly — no re-bootstrapping per run. It also adds scheduling features cron lacks: a persistent jobstore that remembers the last run, misfire_grace_time to catch up a run missed during downtime, and max_instances to stop a slow rebuild overlapping itself. The trade-off is a shared crash domain — a rebuild that exhausts memory can take the web server with it — and the multi-worker duplication problem you must guard against. When one server stops being enough, both give way to a distributed queue; see running map-rebuild jobs as Celery tasks with Redis, and scheduled map rebuild workflows for the broader menu of triggers.

Process isolation of cron versus shared context of APScheduler On the left, the cron daemon forks a separate isolated process to run the rebuild script, which bootstraps its own config and database connection. On the right, APScheduler runs the rebuild as a thread inside the web server process, sharing the app config and connection pool. system cron — isolated process cron daemon reads crontab forked process own config + DB own crash domain fork web server untouched rebuild crash stays isolated APScheduler — shared context web server process request handlers scheduler job thread shared config + warm connection pool rebuild crash can take the server with it

Production-Ready Implementation

The same nightly rebuild, written both ways, so the difference is concrete. First the cron form: a self-contained script that bootstraps its own state, plus the crontab line that runs it. flock in the crontab prevents overlapping runs.

# rebuild_job.py — standalone, no live app state; cron runs this in isolation.
from __future__ import annotations

import logging
import os
import sys
from pathlib import Path


def main() -> int:
    logging.basicConfig(level=logging.INFO)
    # The script bootstraps EVERYTHING itself — no shared app context exists.
    output_dir = Path(os.getenv("TILE_OUTPUT_DIR", "dist/tiles"))
    db_url = os.getenv("DATABASE_URL", "")  # open its own connection here
    output_dir.mkdir(parents=True, exist_ok=True)
    # ... connect, run tippecanoe / regenerate GeoJSON, atomic-rename output ...
    logging.info("cron rebuild complete -> %s", output_dir)
    return 0


if __name__ == "__main__":
    sys.exit(main())
# crontab -e  — 02:00 daily. flock stops a slow run from overlapping the next.
# The isolated process cannot affect the running dashboard.
0 2 * * *  /usr/bin/flock -n /tmp/tile_rebuild.lock \
  /srv/dashboard/.venv/bin/python /srv/dashboard/rebuild_job.py \
  >> /var/log/tile_rebuild.log 2>&1

The APScheduler form runs the identical logic inside the app, reusing its config and connection pool, and gains misfire catch-up that cron cannot offer:

# Inside the Flask app process — shares config, pool, caches, app_context.
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from flask import Flask, current_app


def rebuild_tiles() -> None:
    # Reuses the LIVE app: no re-bootstrapping, one source of config truth.
    output_dir = current_app.config["TILE_OUTPUT_DIR"]
    pool = current_app.extensions["db_pool"]  # already-warm connection pool
    # ... regenerate tiles using the shared pool, atomic-rename output ...


def attach_scheduler(app: Flask) -> BackgroundScheduler:
    scheduler = BackgroundScheduler(
        jobstores={"default": SQLAlchemyJobStore(url=app.config["SCHEDULER_DB_URL"])},
        timezone="UTC",
        job_defaults={"coalesce": True, "max_instances": 1, "misfire_grace_time": 3600},
    )
    scheduler.add_job(
        func=lambda: app.app_context().push() or rebuild_tiles(),
        trigger=CronTrigger(hour=2, minute=0),
        id="nightly_tile_rebuild",
        replace_existing=True,
    )
    scheduler.start()  # guard so only ONE worker starts it (see the APScheduler guide)
    return scheduler

Decision Table

Dimension System cron APScheduler (in-process)
Application context none — script bootstraps its own full — shares config, pool, caches, app_context
Crash isolation strong — separate OS process weak — shares the web server process
Missed-run catch-up no — a run due during downtime is skipped yes — misfire_grace_time + persistent jobstore
Overlap protection manual (flock) built-in (max_instances=1)
Sub-minute / interval schedules no (1-minute granularity) yes (IntervalTrigger, seconds)
Multi-worker duplication not an issue (single OS entry) must guard against N workers scheduling N copies
Operational visibility OS logs, mailto on failure in-app logging, jobstore rows, /healthz
Deploy coupling independent of app deploys restarts with the app

Reading the table: if the rows you care about are crash isolation and independence from the app, cron wins. If they are shared context, missed-run catch-up, or sub-minute cadence, APScheduler wins — at the price of guarding against multi-worker duplication.

Verification Steps

  • Context need test — try to write the rebuild as a plain script. If it re-implements config loading and connection setup that already exist in the app, that duplication is the signal to prefer APScheduler.
  • Isolation test — force the rebuild to allocate excess memory. Under cron the web server must stay responsive; under APScheduler confirm you have resource limits, because it will not.
  • Missed-run test — stop the machine across a scheduled time. Confirm cron skips it entirely, and that APScheduler with misfire_grace_time runs it once on restart.
  • Overlap test — make the job run longer than its interval; confirm flock (cron) or max_instances=1 (APScheduler) prevents a second concurrent run.
  • Duplication test (APScheduler only) — run under multiple workers and confirm exactly one process owns the job.

Common Errors & Fixes

cron job runs from the wrong directory or with missing environment

cron executes with a minimal environment and a HOME-based working directory, not your shell’s. Relative paths and variables from .bashrc are absent, so the script fails to find config or the virtualenv. Fix: use absolute paths for the interpreter and script, set required variables explicitly at the top of the crontab, and cd inside the script rather than assuming a working directory.

APScheduler rebuild fails with “working outside of application context”

The job function touched current_app or a database session without an active app_context. Unlike a request handler, a scheduled job has no context pushed automatically. Fix: push an app_context() inside the job (or wrap the call) before accessing current_app, config, or the ORM session.

A rebuild crash took down the whole dashboard

An APScheduler job exhausted memory or raised in a way that destabilised the shared process, and because the web server lives in that same process it went down too. Fix: if strict isolation matters more than shared context, move the job to cron where it runs in a separate process, or apply per-process resource limits so a runaway rebuild is killed before it starves the server.

The scheduled run silently never happened

For cron, the machine was likely off at the due minute and cron has no catch-up. For APScheduler, the run was missed and no misfire_grace_time was set, so it was skipped. Fix: for jobs that must not be skipped, choose APScheduler with a persistent jobstore and a misfire_grace_time, or add an at-startup check to cron scripts that detects a stale artifact and rebuilds on boot.