Background Jobs and Cron in LangGraph
Running LangGraph background jobs means taking a compiled graph out of the request/response cycle and executing it on a schedule or from a queue instead of a chat turn. LangGraph itself has no built-in cron, no daemon, and no job table. It compiles a graph, gives you invoke, ainvoke, stream, and checkpointing, and stops there. Everything about "run this every night at 2am" or "run this when a webhook fires and don't block the caller" is your infrastructure to build. This article walks through the three patterns that actually work in production: OS-level cron plus a Python entrypoint, a proper task queue (Celery or RQ) for anything that needs retries, and LangGraph Platform's own cron and webhook support if you're already running on their infra.
Why LangGraph background jobs are not built in
LangGraph is a graph execution engine, not an orchestration platform. A compiled graph is a Python object with .invoke() and .stream() methods. When you call it inside a FastAPI route handler, it runs synchronously (or async, if you use ainvoke) inside that request. For a chatbot turn that takes two seconds, that's fine. For an agent that reads a mailbox, calls three tools, and writes a report, a request taking ninety seconds will time out most load balancers and definitely will time out most reverse proxies with default settings.
The fix is always the same shape regardless of stack: decouple "trigger" from "execution." A trigger (cron tick, webhook, queue message) starts the job. Execution happens somewhere that isn't holding an HTTP connection open. LangGraph's job in that picture is just the unit of work, the thing that gets executed. It doesn't know or care whether it was called by a cron daemon, a Celery worker, or a Lambda invocation.
This matters because a lot of people search for "LangGraph scheduler" expecting a decorator like @graph.schedule("0 2 * * *"). That doesn't exist in the open source package. If you want it, you either build the wiring yourself (the rest of this article) or use LangGraph Platform, Anthropic's and LangChain's own hosted control plane, which does add cron and webhook primitives on top of the same open source graph engine.
Pattern 1: OS cron plus a standalone entrypoint script
The simplest and most durable pattern for anything running on a VM, a Docker host, or bare metal: write a script that builds and invokes your graph, then let cron or systemd timers call it.
Start with a graph that does real work, for example a daily digest agent that pulls new support tickets and summarizes them:
# digest_graph.py
from typing import TypedDict
from langgraph.graph import StateGraph, END
from langchain_anthropic import ChatAnthropic
class DigestState(TypedDict):
tickets: list[dict]
summary: str
llm = ChatAnthropic(model="claude-sonnet-4-5")
def fetch_tickets(state: DigestState) -> DigestState:
# replace with your actual ticket source
tickets = fetch_open_tickets_since_yesterday()
return {"tickets": tickets}
def summarize(state: DigestState) -> DigestState:
text = "\n".join(f"- {t['title']}: {t['body'][:200]}" for t in state["tickets"])
resp = llm.invoke(f"Summarize these support tickets into 5 bullet points:\n{text}")
return {"summary": resp.content}
def build_graph():
g = StateGraph(DigestState)
g.add_node("fetch_tickets", fetch_tickets)
g.add_node("summarize", summarize)
g.add_edge("fetch_tickets", "summarize")
g.add_edge("summarize", END)
g.set_entry_point("fetch_tickets")
return g.compile()Now write a thin entrypoint that runs it once and exits:
# run_digest.py
import sys
from digest_graph import build_graph
def main():
graph = build_graph()
result = graph.invoke({"tickets": [], "summary": ""})
print(result["summary"])
send_to_slack(result["summary"])
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"digest job failed: {exc}", file=sys.stderr)
sys.exit(1)Wire it into cron:
# crontab -e
0 2 * * * cd /opt/app && /opt/app/venv/bin/python run_digest.py >> /var/log/digest.log 2>&1Or, if you're on Linux with systemd, prefer a timer unit over crontab because you get structured logging via journald and dependency ordering for free:
# /etc/systemd/system/langgraph-digest.service
[Unit]
Description=LangGraph nightly digest
[Service]
Type=oneshot
WorkingDirectory=/opt/app
ExecStart=/opt/app/venv/bin/python run_digest.py
User=appuser# /etc/systemd/system/langgraph-digest.timer
[Unit]
Description=Run LangGraph digest nightly
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.targetEnable it with systemctl enable --now langgraph-digest.timer. Persistent=true means if the box was down at 2am, the job runs as soon as it comes back up, which cron does not give you.
This pattern is the right default when: the job runs on a fixed schedule, there's no need for a retry queue, and you already have a server you control. It's boring and that's the point, boring means fewer moving parts to debug at 3am.
Pattern 2: Task queue with Celery or RQ for triggered, retryable jobs
Cron is wrong when the job is triggered by an event (a webhook, a user action) rather than a clock, or when you need retries with backoff, concurrency limits, or visibility into a queue depth. That's when you put a real task queue in front of LangGraph.
Redis Queue (RQ) is the simpler of the two options and is a good fit if you're already running Redis:
# tasks.py
from redis import Redis
from rq import Queue
from digest_graph import build_graph
redis_conn = Redis(host="localhost", port=6379)
queue = Queue("langgraph-jobs", connection=redis_conn)
def run_agent_job(initial_state: dict) -> dict:
graph = build_graph()
return graph.invoke(initial_state)Enqueue from your web handler and return immediately:
# api.py
from fastapi import FastAPI
from tasks import queue, run_agent_job
app = FastAPI()
@app.post("/webhooks/new-ticket")
async def new_ticket(payload: dict):
job = queue.enqueue(run_agent_job, payload, job_timeout="10m", retry=Retry(max=3, interval=[10, 30, 60]))
return {"status": "queued", "job_id": job.id}Run workers as separate processes:
rq worker langgraph-jobs --url redis://localhost:6379Celery is heavier but gives you a scheduler (Celery Beat) in the same package as the queue, so you can cover both the cron case and the triggered case with one system if you'd rather not run OS cron at all:
# celery_app.py
from celery import Celery
from digest_graph import build_graph
app = Celery("langgraph_jobs", broker="redis://localhost:6379/0", backend="redis://localhost:6379/1")
@app.task(bind=True, max_retries=3, default_retry_delay=30)
def run_digest_task(self):
try:
graph = build_graph()
return graph.invoke({"tickets": [], "summary": ""})
except Exception as exc:
raise self.retry(exc=exc)
app.conf.beat_schedule = {
"nightly-digest": {
"task": "celery_app.run_digest_task",
"schedule": crontab(hour=2, minute=0),
},
}Run the worker and the beat scheduler as two processes:
celery -A celery_app worker --loglevel=info
celery -A celery_app beat --loglevel=infoA few things worth calling out that trip people up:
- Serialize state, not graph objects. RQ and Celery pickle or JSON-encode task arguments. Never pass a compiled graph across the queue boundary, rebuild it inside the worker function using
build_graph(). Graphs hold LLM client objects, HTTP connections, sometimes thread locks, none of which survive serialization. - Set a hard timeout. Agent loops can get stuck in tool-call retries.
job_timeout="10m"on RQ ortime_limiton Celery gives you a forced kill instead of a worker that hangs forever. - Idempotency matters more with retries. If
run_agent_jobsends a Slack message and then the queue retries it because of a transient network blip mid-run, you get a duplicate message. Either make the last step idempotent (check "have I already posted for this ticket ID today") or split the graph so side effects only happen in a final node that's easy to guard.
Pattern 3: LangGraph checkpointing for durable, resumable background runs
Long-running agent jobs, the kind that call several tools, wait on external APIs, or run for minutes, benefit from LangGraph's checkpointer even outside of interactive chat. A checkpointer persists graph state after every node, so if the worker process crashes mid-run, you can resume from the last completed node instead of restarting from scratch.
from langgraph.checkpoint.postgres import PostgresSaver
def build_graph():
g = StateGraph(DigestState)
g.add_node("fetch_tickets", fetch_tickets)
g.add_node("summarize", summarize)
g.add_edge("fetch_tickets", "summarize")
g.add_edge("summarize", END)
g.set_entry_point("fetch_tickets")
checkpointer = PostgresSaver.from_conn_string("postgresql://user:pass@localhost/langgraph")
return g.compile(checkpointer=checkpointer)Every invocation needs a thread_id so the checkpointer knows which run to save and resume:
config = {"configurable": {"thread_id": f"digest-{today_str}"}}
result = graph.invoke({"tickets": [], "summary": ""}, config=config)If the worker dies after fetch_tickets completes but before summarize runs, calling graph.invoke(None, config=config) with the same thread_id picks up exactly where it left off instead of re-fetching tickets. This is the single most useful thing to add if your background jobs call paid APIs or do anything non-idempotent partway through, since it turns "process crashed" from "start over and maybe double-charge an API" into "resume the next node."
Pair this with your queue: the RQ or Celery task becomes a thin wrapper that just calls graph.invoke(state, config={"configurable": {"thread_id": job_id}}), and the queue's retry mechanism combined with checkpointing means a retry resumes instead of restarts.
Pattern 4: LangGraph Platform's built-in cron and webhooks
If you deploy graphs to LangGraph Platform (the hosted or self-hosted control plane from LangChain), you get cron and webhook triggers as part of the deployment config instead of building them yourself. This is worth mentioning because a lot of teams reinvent Pattern 1 or 2 without realizing the platform already covers it.
A deployed graph on LangGraph Platform exposes a REST API, and the platform's SDK lets you create scheduled runs:
from langgraph_sdk import get_client
client = get_client(url="https://your-deployment.langgraph.app")
await client.crons.create(
assistant_id="digest-agent",
schedule="0 2 * * *",
input={"tickets": [], "summary": ""},
)The platform handles the actual triggering, retries on infrastructure failure, and run history in its own UI. The tradeoff is you're now depending on their control plane rather than your own cron or queue, which is the right call if you don't want to operate that infra yourself, and the wrong call if you need on-prem or have compliance constraints that rule out a managed service. Check current LangGraph Platform docs for schedule syntax and limits before committing, since hosted product details change faster than the open source library.
Choosing between the patterns
Use OS cron plus a script when the job runs on a fixed schedule, doesn't need retries beyond "cron will try again tomorrow," and you already control a server. This is most internal reporting and digest jobs.
Use a task queue (RQ for simplicity, Celery if you want beat scheduling in the same system) when jobs are triggered by events, need retry-with-backoff, or need concurrency control so you don't run fifty agent instances at once against a rate-limited API.
Add a checkpointer regardless of which trigger mechanism you pick, the moment a background graph run takes longer than a few seconds or touches anything non-idempotent. It costs one Postgres table and a thread_id and it turns crashed workers from a data-loss event into a resume-from-checkpoint event.
Use LangGraph Platform's cron and webhooks only if you're already deploying there. It's not worth adopting the platform purely to get scheduling; OS cron and a queue get you 90% of the value with tools you already know how to operate.
FAQ
Does LangGraph have a built-in scheduler? No. The open source langgraph package has no cron, timer, or job queue. You trigger graphs yourself, either with OS-level cron/systemd timers, a task queue like Celery or RQ, or LangGraph Platform's hosted cron feature if you deploy there.
Can I run a LangGraph agent inside a Celery task? Yes, and it's a common pattern. Rebuild the compiled graph inside the Celery task function rather than passing a graph object as a task argument, since compiled graphs aren't reliably serializable across the broker. Call graph.invoke(state, config=...) inside the task body.
How do I stop a background LangGraph run from blocking my web server? Never call graph.invoke() directly inside a request handler for anything that takes more than a couple of seconds. Enqueue the work (RQ, Celery, or even a plain asyncio.create_task for lightweight fire-and-forget cases) and return a job ID to the caller immediately.
What happens if my worker crashes mid-run? Without a checkpointer, you lose all progress and have to restart the graph from the beginning. With a checkpointer (Postgres, SQLite, or Redis-backed) and a consistent thread_id, you can call graph.invoke(None, config={"configurable": {"thread_id": job_id}}) to resume from the last completed node.
Should I use Celery Beat or OS cron for scheduling? If you already need Celery for retryable, event-triggered jobs, use Celery Beat too and keep scheduling in one system. If your only need is "run this script nightly" and you have no other queue infrastructure, plain cron or a systemd timer is fewer moving parts and easier to debug.
Can LangGraph jobs call external APIs on a retry-safe basis? LangGraph itself doesn't add retry logic to tool calls; that's on you. Wrap tool functions with a retry decorator (tenacity is the common choice in Python) for transient failures, and rely on your queue's retry mechanism (Celery's max_retries, RQ's Retry class) for whole-job retries after a crash or timeout.
Is LangGraph Platform's cron feature free? Pricing and feature availability for LangGraph Platform change over time, check the current LangChain docs and pricing page before planning around it. Don't assume last year's tier limits still apply.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.