How to Schedule Web Scrapers: cron to Airflow
Learn how to schedule web scrapers at any scale, from cron and systemd timers to Airflow DAGs, with idempotent runs, retries, jitter, and proxy config.

You can schedule web scrapers with one crontab line, and for a single script that runs nightly, you should. The trouble starts when that job has to run every hour, retry cleanly, skip work it already finished, page you when it silently stops, and not slam the target at the same second as fifty other jobs. This guide covers how to automate web scraping at every scale, from cron and systemd timers up to Airflow and Prefect DAGs, with the production details most tutorials skip: idempotent incremental runs, retries and alerting, jitter to dodge the thundering herd, and safe secrets plus proxy config.
Why scheduling breaks scrapers that work by hand
A scraper you run by hand has a human in the loop. You watch it finish, you notice the 403s, you rerun it when something looks off. A scheduled scraper has none of that. It runs at 3 a.m., and whatever it does, it does alone.
That changes what "working" means. Four failure modes show up the moment you automate:
- Silent failure. The job stops firing, or it fires and returns empty pages, and nobody finds out until a dashboard downstream goes flat a week later. A crashed cron job emits no error to a human.
- Overlap. A run takes longer than its interval, so the next run starts while the first is still going. Now two copies fight over the same files, the same rows, and the same target IP.
- Redundant work. Every run re-scrapes the entire site from scratch, burning time and request budget on pages that have not changed since yesterday.
- The thundering herd. You add a second scraper, then a tenth, and they all fire at the top of the hour. The target sees a spike from your address range every 60 minutes, and starts blocking.
None of these appear in a "run this script" tutorial because a human standing at the terminal papers over all four. Automation removes the human, so the schedule has to do that job instead. The rest of this guide is about building those guarantees in.
Pick a scheduler by scale
There is no single best scheduler. There is the lightest tool that covers what you actually need. Match the row to your situation and do not reach further up the ladder than the work demands.
| Scale / need | Best tool | Why it fits | Watch out for |
|---|---|---|---|
| One box, a few jobs, simple cadence | cron | Already on every Linux host, zero dependencies | No retries, no alerting, overlaps unless you add `flock` |
| One box, want catch-up and jitter | systemd timers | Built-in `RandomizedDelaySec`, `Persistent` catch-up, journald logs | More verbose than cron, systemd only |
| No server, code lives in GitHub | GitHub Actions (`schedule`) | Serverless, secrets built in, free minutes | UTC only, delayed under load, paused after 60 days of repo inactivity |
| Jobs tied to a web app or task queue | Celery beat | Reuses the broker and workers you already run | Single beat process, needs a lock for high availability |
| Multi-step pipelines, dependencies, backfills | Airflow or Prefect | DAGs, retries, backfills, a UI, data-interval scheduling | Heavier to run and to operate |
The progression is deliberate. cron and systemd cover a huge share of real scraping work, and plenty of teams never need more. You climb to Celery beat when scraping is already part of an application, and to Airflow or Prefect when one scrape feeds another and the dependency graph matters.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
cron and systemd timers: the workhorse
For a single machine, cron is still the right answer more often than the internet admits. The mistake is treating a raw cron line as finished. A production cron web scraper line does four things at once: it offsets the minute so it is not on the hour, it locks against overlap, it runs inside the project's virtualenv, and it captures logs.
# /etc/cron.d/scrapers (user field is required in /etc/cron.d)
# Run the price scraper every 6 hours at :17, never on the hour.
17 */6 * * * scraper /usr/bin/flock -n /tmp/price.lock /opt/scrapers/venv/bin/python /opt/scrapers/price.py >> /var/log/scrapers/price.log 2>&1
flock -n grabs an exclusive lock and exits immediately if the previous run still holds it, which kills overlap for free. The 2>&1 sends stderr into the same log, so a stack trace is not lost. The :17 minute is the cheapest thundering-herd fix there is.
cron has real gaps, though. It never retries, it does not alert, and if the box was asleep at the scheduled time, that run is simply gone. systemd timers close those gaps. You write two files, a service and a timer:
# /etc/systemd/system/price-scraper.service
[Unit]
Description=Price scraper
[Service]
Type=oneshot
EnvironmentFile=/opt/scrapers/.env
ExecStart=/opt/scrapers/venv/bin/python /opt/scrapers/price.py
# /etc/systemd/system/price-scraper.timer
[Unit]
Description=Run the price scraper every 6 hours
[Timer]
OnCalendar=*-*-* 00/6:00:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target
Enable it with systemctl enable --now price-scraper.timer. Three lines earn their keep here. RandomizedDelaySec=900 spreads the real start across a 15-minute window, so a fleet of timers never fires in lockstep. Persistent=true runs a missed job as soon as the machine is back, which cron cannot do. EnvironmentFile keeps secrets out of the unit and out of your shell history. Logs land in journald, queryable with journalctl -u price-scraper.service.
GitHub Actions for serverless schedules
When you have no server to babysit and the code already lives in a repo, GitHub Actions runs scheduled scraping on someone else's machine. Secrets, runners, and logs come included.
# .github/workflows/scrape.yml
name: scheduled-scrape
on:
schedule:
- cron: "23 */6 * * *" # every 6h at :23; cron here is UTC only
workflow_dispatch: {} # lets you trigger a run by hand too
jobs:
scrape:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Run scraper
env:
SPARKPROXY_API_KEY: ${{ secrets.SPARKPROXY_API_KEY }}
run: python price.py
Three quirks are worth knowing before you rely on this. The cron schedule is UTC with no timezone option, so account for that when a job needs to line up with a local business day. Scheduled runs are best-effort and get delayed when GitHub's queue is busy, sometimes by many minutes, so do not use Actions for second-precise timing. And a schedule on a repository with no pushes for 60 days is automatically disabled, which has silently killed more than one long-running collector. Add workflow_dispatch so you always have a manual trigger, and commit the state file the scraper writes so incremental runs survive across ephemeral runners.
Celery beat for app-integrated jobs
If your scraping already lives inside a Python app with a task queue, standing up a second scheduler is wasted effort. Celery beat schedules tasks onto the workers you already run.
# celery_app.py
from celery import Celery
from celery.schedules import crontab
app = Celery("scrapers", broker="redis://localhost:6379/0")
app.conf.beat_schedule = {
"scrape-prices-6h": {
"task": "tasks.scrape_prices",
"schedule": crontab(minute=41, hour="*/6"),
"options": {"expires": 60 * 30}, # drop the job if it hasn't started in 30 min
},
}
# tasks.py
from celery_app import app
@app.task(bind=True, acks_late=True, max_retries=5,
default_retry_delay=60, autoretry_for=(Exception,),
retry_backoff=True, retry_jitter=True)
def scrape_prices(self):
rows = run_scrape()
upsert(rows)
Run the two processes side by side: celery -A celery_app worker and celery -A celery_app beat. The task decorator carries the reliability story. acks_late=True means the message is only acknowledged after the task finishes, so a worker that dies mid-scrape hands the job to another worker instead of dropping it. retry_backoff with retry_jitter spaces out retries and adds randomness so a batch of failures does not retry in a synchronized wave. The one operational rule: run exactly one beat process. Two beats double-schedule everything, so for high availability you guard it with a lock or use a single-leader deployment.
For the async patterns that pair well with Celery workers, our guide on using proxies with Python requests and aiohttp for async scraping covers the fetch side in depth.
Airflow and Prefect for real pipelines
Once a scrape is one step in a longer chain, discover URLs, fetch them, parse, then load into a warehouse, you want a DAG. Airflow and Prefect give you dependencies, retries per task, backfills over a date range, and a UI that shows exactly which task failed and why. This is where airflow scraping earns the extra operational weight.
Here is a real Airflow 2.x pipeline using the TaskFlow API and dynamic task mapping:
# dags/price_pipeline.py
from datetime import timedelta
import pendulum
from airflow.decorators import dag, task
@dag(
schedule="17 */6 * * *",
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False, # don't backfill every missed interval on first deploy
max_active_runs=1, # never let two runs of this DAG overlap
default_args={
"retries": 4,
"retry_delay": timedelta(minutes=5),
"retry_exponential_backoff": True,
"max_retry_delay": timedelta(minutes=30),
},
tags=["scraping"],
)
def price_pipeline():
@task
def discover(data_interval_start=None) -> list[str]:
# Only the URLs that changed since the last interval: incremental by design.
return urls_changed_since(data_interval_start)
@task(max_active_tis_per_dag=8) # cap concurrent fetches so you don't flood the target
def fetch(url: str) -> dict:
return fetch_via_sparkproxy(url)
@task
def load(rows: list[dict]) -> None:
upsert(rows)
urls = discover()
rows = fetch.expand(url=urls) # one mapped task per URL, fanned out automatically
load(rows.map(lambda r: r))
price_pipeline()
Two settings do the heavy lifting. max_active_runs=1 stops overlap at the pipeline level, and max_active_tis_per_dag=8 caps how many fetch tasks run at once, which is your rate lever against the target. catchup=False matters on the first deploy: without it, Airflow tries to backfill every interval since start_date in one burst, which is a self-inflicted thundering herd.
Prefect expresses the same idea with less ceremony, and its schedule lives with the deployment rather than in a separate daemon:
# flow.py
from prefect import flow, task
@task(retries=4, retry_delay_seconds=[30, 120, 300, 600])
def fetch(url: str) -> dict:
return fetch_via_sparkproxy(url)
@flow(log_prints=True)
def price_pipeline(urls: list[str]):
results = fetch.map(urls)
upsert([r.result() for r in results])
if __name__ == "__main__":
# Serve the flow with a cron schedule; no separate scheduler process to babysit.
price_pipeline.serve(name="prices", cron="17 */6 * * *")
The retry_delay_seconds list gives explicit backoff steps, and serve(cron=...) registers the schedule in one line. Pick Airflow when you already run it or need its ecosystem, and Prefect when you want DAG features without operating a metadata database and scheduler yourself.
Make every run idempotent and incremental
Idempotency is the property that running a job twice leaves the same result as running it once. It is the single most important habit for scheduled scraping, because retries, overlaps, and catch-up runs all mean your job will run more than once on the same data.
Two mechanics get you there. The first is a watermark: store the timestamp (or the last ID) you reached, and next run start from there instead of from zero.
# state.py
import json, pathlib
STATE = pathlib.Path("state/price.json")
def load_watermark() -> str:
if STATE.exists():
return json.loads(STATE.read_text())["last_run"]
return "1970-01-01T00:00:00Z"
def save_watermark(ts: str) -> None:
STATE.parent.mkdir(parents=True, exist_ok=True)
STATE.write_text(json.dumps({"last_run": ts}))
The watermark turns a full re-scrape into an incremental one. Instead of pulling 40,000 product pages every six hours, you pull the few hundred that changed since load_watermark(), then advance the watermark only after the load succeeds. If the run dies halfway, the watermark stays put and the next run retries the same slice cleanly.
The second mechanic is an upsert at the storage layer, so a re-run overwrites instead of duplicating:
INSERT INTO prices (sku, price, currency, scraped_at)
VALUES (%(sku)s, %(price)s, %(currency)s, %(scraped_at)s)
ON CONFLICT (sku) DO UPDATE
SET price = EXCLUDED.price,
currency = EXCLUDED.currency,
scraped_at = EXCLUDED.scraped_at;
With a unique key and ON CONFLICT DO UPDATE, running the same batch twice produces one row per SKU, not two. Combine the watermark and the upsert and your scraper becomes safe to retry, safe to overlap, and cheap to run, because it only ever touches new work.
Retries, alerting, and monitoring
Networks fail, targets return 429s, and pages occasionally load half-empty. A scheduled scraper has to expect transient failure and recover without a human. Wrap the fetch in retries with exponential backoff and jitter using tenacity:
import requests
from tenacity import (retry, stop_after_attempt,
wait_exponential_jitter, retry_if_exception_type)
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=2, max=60),
retry=retry_if_exception_type(requests.RequestException),
reraise=True,
)
def fetch(url: str) -> requests.Response:
r = requests.get(url, timeout=60)
r.raise_for_status()
return r
wait_exponential_jitter backs off 2s, then roughly 4s, 8s, and so on, with random jitter added so a burst of failures does not retry in a synchronized wave. Cap the attempts so a genuinely dead target does not stall the whole run.
Retries handle the failures you can see. The dangerous failures are the ones you cannot: the job that stops running entirely. A scraper that never fires produces no error and no log line, so ordinary alerting never triggers. The fix is a dead man's switch, a heartbeat you ping on every success. If the ping stops arriving, the monitoring service alerts you.
import requests
def heartbeat(ok: bool) -> None:
# Ping a monitor (Healthchecks.io, Cronitor, etc.) after each run.
suffix = "" if ok else "/fail"
try:
requests.get(f"https://hc-ping.com/{PING_KEY}{suffix}", timeout=10)
except requests.RequestException:
pass # never let the heartbeat crash the scraper
Beyond the heartbeat, track a handful of signals per run so you catch slow decay, not just hard failure:
| Signal | What it tells you | Alert when |
|---|---|---|
| Last successful run time | The job is actually firing | No success in 2x the interval (dead man's switch) |
| Rows written per run | The parser still matches the page | It drops toward zero (the layout changed) |
| Error rate by status | Blocks versus bugs | 429/403 climb, or 5xx spikes |
| Run duration | Drift toward the next window | It approaches the schedule interval |
| Credits or requests per run | Cost and hidden retry storms | A sudden jump versus baseline |
The "rows toward zero" signal is the one people forget. A site redesign does not throw an error. Your selectors just stop matching, the scraper writes zero rows, and it reports success the whole time. Alert on output volume, not only on exit codes.
Avoid the thundering herd with jitter
The thundering herd is what happens when many jobs wake at the same instant. Schedule ten scrapers at 0 and the target sees ten simultaneous bursts from your IP range at the top of every hour. That pattern is trivial to rate-limit and looks nothing like human traffic.
Jitter breaks the synchronization. You have already seen three ways to add it:
- Offset the minute. Use
:17or:23, never:00, and give each job a different offset. - Let the scheduler splay it. systemd's
RandomizedDelaySecand Airflow'smax_active_tis_per_dagboth spread real start times. - Splay inside the job. Add a short random sleep before the first request so even jobs on the same cron minute do not fire together.
import random, time
# At the top of the scraper, before the first request.
time.sleep(random.uniform(0, 90)) # spread the real start across a 90-second window
Jitter matters between requests too, not just at start-up. Fixed delays are as detectable as no delay, because human clicks are never metronomic. For steady-state pacing across a large run, our guide on how to scrape high-volume data without rate limiting goes deep on request budgets and concurrency, which is the other half of not looking like a herd.
Secrets and proxy config for scheduled jobs
An unattended job cannot type a password, so its credentials sit somewhere on disk or in the environment. Get this wrong and you leak an API key into a public repo or a world-readable crontab. A few rules keep scheduled jobs safe:
- Never hardcode keys in the script, the crontab line, or a committed file. A crontab is readable, and
gitremembers everything. - Inject secrets through the environment. Use systemd's
EnvironmentFile, GitHub Actionssecrets, or a.envloaded withpython-dotenvand listed in.gitignore. - Reach for a secret manager at scale. Vault, AWS Secrets Manager, Doppler, or your platform's equivalent handle rotation and audit for you.
- Give each job its own credential so you can revoke one scraper without breaking the rest, and rotate on a schedule.
import os
API_KEY = os.environ["SPARKPROXY_API_KEY"] # fail loudly if the secret is missing
Proxy configuration follows the same logic. A scheduled job that manages its own rotating proxy list has to persist and update that list across runs, which is state you do not want on an ephemeral runner. The cleaner pattern is to push proxy selection, rotation, and geo-targeting behind a single fetch layer, so the job carries one secret and stays stateless. If your scrapers manage raw proxies directly, our notes on how to avoid getting your proxy blocked cover the hygiene that keeps an automated job off blocklists.
Calling the SparkProxy Scraping API from a scheduled job
The SparkProxy Scraping API is a good fit for scheduled work precisely because it removes state from the job. It handles proxy rotation, JavaScript rendering, and geo-targeting on the server side, so your cron line, timer, or DAG task holds one secret (the API key) and never touches a proxy list. Every fetch helper in this guide, fetch_via_sparkproxy, is this function:
import os
import requests
from tenacity import (retry, stop_after_attempt,
wait_exponential_jitter, retry_if_exception_type)
API = "https://scrape.sparkproxy.io/api/v1"
KEY = os.environ["SPARKPROXY_API_KEY"]
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=2, max=60),
retry=retry_if_exception_type(requests.RequestException),
reraise=True,
)
def fetch_via_sparkproxy(url: str, country: str = "us") -> dict:
resp = requests.get(
API,
headers={"X-API-Key": KEY},
params={
"url": url,
"render_js": "true", # server-side headless Chromium
"country_code": country, # geo-target without your own regional proxies
"json_response": "true", # wrap the result so we can read status + credits
},
timeout=90,
)
resp.raise_for_status()
data = resp.json()
if data["status_code"] >= 400:
# A 429 or 403 from the target is retryable; raise so tenacity backs off.
raise requests.HTTPError(f"target returned {data['status_code']}")
return data
With json_response=true, the response envelope carries credits_used, duration_ms, and status_code, which feed directly into the monitoring table above: log credits_used per run and you have your cost-and-retry-storm signal for free.
Long renders are the other scheduling headache. If a page takes 30 seconds to load, a synchronous request ties up the scheduler slot for the whole time. The API's webhook mode fixes that. Pass a callback_url and the request returns 202 immediately, then POSTs the finished result to your endpoint later. That decouples slow fetches from a tight cron window, which is exactly what you want when a batch runs every few minutes.
Whether to hand proxy management to an API at all is a real decision with tradeoffs, and our comparison of a web scraping API versus self-managed proxies lays out when each one wins. For scheduled, unattended jobs, the stateless fetch layer usually pays off, because the fewer moving parts a 3 a.m. job has, the fewer ways it fails while you sleep.
Frequently asked questions
FAQ
Use cron for simple cadence and systemd timers when you want retries context and catch-up. A production cron line should offset the minute off the hour, wrap the command in flock to prevent overlap, run inside the project virtualenv, and redirect output to a log. systemd adds RandomizedDelaySec for jitter and Persistent=true to run jobs missed while the machine was off.
It depends on the shape of the work. cron is better for a single independent script on one box, since it is already installed and needs no infrastructure. Airflow is better once a scrape is one step in a multi-task pipeline with dependencies, backfills, and per-task retries, because a DAG models that and cron cannot. Do not run Airflow just to fire one nightly script.
Make the run incremental with a watermark. Store the last timestamp or ID you reached, start the next run from there, and advance the watermark only after the load succeeds. Pair it with an upsert (INSERT ... ON CONFLICT DO UPDATE) so retries and overlaps overwrite rows instead of duplicating them. Together they make the job idempotent and cheap.
Use a dead man's switch. Ping a monitoring service such as Healthchecks.io or Cronitor on every successful run, and configure it to alert you when the ping stops arriving. Ordinary error alerting misses a job that never fires or one that returns empty pages, so also alert on rows-written dropping toward zero, not only on non-zero exit codes.
The thundering herd is many jobs waking at the same instant, usually the top of the hour, which produces a detectable traffic spike from your IP range. Avoid it by adding jitter: offset each job's cron minute, use systemd RandomizedDelaySec or an in-job time.sleep(random.uniform(0, 90)), and cap concurrency in your DAG. Randomized start times spread the load and look far less robotic.
Push proxy selection and rotation behind a single fetch layer instead of persisting a rotating list on an ephemeral runner. A scraping API such as SparkProxy handles rotation, geo-targeting, and JavaScript rendering server-side, so the scheduled job carries one API key and stays stateless. That removes the state a 3 a.m. job is most likely to corrupt, and it keeps proxy hygiene out of your cron line.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Handle Cookies and Sessions When Web Scraping
Handle cookies and sessions when web scraping: cookie jars, Set-Cookie parsing, CSRF tokens, login state, disk persistence, and sticky proxy IPs, in Python.

Async Web Scraping in Python: A Concurrency Guide
Master async web scraping in Python with asyncio, httpx and aiohttp: bound concurrency with semaphores, size connection pools, and isolate failures.

How to Scrape Google News: RSS, Headlines, and Feeds
Scrape Google News with its free RSS feeds: pull headlines and articles, decode the redirected article links, and geo-target editions with hl, gl, and ceid.
