teachyou.ai academy
← All posts
LangGraph

LangGraph for Long-Running Workflows: Pausing and Resuming Days Later

Ira Menon · Jun 18, 2026 · 17 min read

Most agent tutorials assume your workflow finishes in one shot. The user asks a question, the agent loops through a few tool calls, and an answer comes back in thirty seconds. Real business processes do not work that way. A contract review needs sign-off from legal, who might respond on Thursday. A refund agent needs a manager's approval, and the manager is on a flight. An onboarding pipeline waits for a document the customer will upload sometime next week. The moment your workflow has to outlive the Python process that started it, the naive approach collapses: the process dies, the in-memory state evaporates, and the agent forgets everything it did. LangGraph long running workflows solve this with a deceptively simple idea — every step of the graph writes a checkpoint to durable storage, so the graph can stop at any point, survive a restart or a deployment, and pick up days later exactly where it left off. In this article we will build that machinery from the ground up: checkpointers, threads, interrupts, resuming with Command, Postgres persistence, and the production pitfalls that only show up after your first workflow has been asleep for a week.

Why Long-Running Workflows Break Ordinary Agent Code

Think about what an agent loop actually holds in memory while it runs: the conversation history, the intermediate tool results, the partially built output, and the position in the control flow — which step comes next. In a plain Python script, all of that lives in local variables. If the process exits, whether because a container was recycled, a deploy rolled out, or a human simply took three days to reply, everything is gone.

Developers usually reach for one of three workarounds, and each one hurts.

The first is blocking. You call input() or await a webhook inside the running process and keep it alive until the human responds. This works in a notebook and fails everywhere else. Holding a process open for days wastes memory, pins you to a single machine, and guarantees data loss on the first restart.

The second is manual state serialization. You pickle your agent's state to a database, write custom code to reload it, and reconstruct the control flow by hand — a status column, a current_step field, a growing pile of if statements deciding where to jump back in. This is essentially reimplementing a workflow engine, badly, and every new branch in your agent doubles the resume logic.

The third is stateless re-execution: when the human responds, just run the whole workflow again from the top. That is tolerable if every step is cheap and idempotent. It is not tolerable when step two was a forty-five-second research phase across twelve LLM calls, or when step three sent an email that must not be sent twice.

LangGraph's answer is architectural rather than a patch. Because you define your agent as a graph of nodes operating on a shared state object, the framework knows exactly where the boundaries between steps are. After each step, it can persist a snapshot of the full state plus a record of which nodes run next. Pausing stops being a hack — it becomes a first-class state the graph can enter and leave. The three primitives that make it work are the checkpointer, the thread, and the interrupt. Let us take them in order.

Checkpointers: The Persistence Layer Under Every Graph

A checkpointer is a storage adapter that LangGraph calls automatically as your graph executes. At the end of each super-step — each round of node execution — the checkpointer writes a checkpoint: a snapshot of the channel values in your state, plus metadata about which node produced them and what is scheduled to run next. You do not call it yourself. You attach it at compile time and the runtime does the rest.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

class RefundState(TypedDict):
    request_id: str
    amount: float
    analysis: str
    approved: bool
    resolution: str

def analyze_request(state: RefundState) -> dict:
    # In real code this calls an LLM to assess the refund claim
    summary = f"Refund of ${state['amount']} for request {state['request_id']}"
    return {"analysis": summary}

def process_refund(state: RefundState) -> dict:
    return {"resolution": "Refund issued"}

builder = StateGraph(RefundState)
builder.add_node("analyze", analyze_request)
builder.add_node("process", process_refund)
builder.add_edge(START, "analyze")
builder.add_edge("analyze", "process")
builder.add_edge("process", END)

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

The second primitive is the thread. When you invoke a graph that has a checkpointer, you must pass a thread_id in the config. A thread is the identity of one workflow instance — one refund case, one contract review, one onboarding run. All checkpoints for that instance accumulate under its thread ID, and any future invocation with the same ID resumes that instance rather than starting fresh.

config = {"configurable": {"thread_id": "refund-case-8842"}}
result = graph.invoke(
    {"request_id": "8842", "amount": 129.0, "approved": False},
    config,
)

This one line is where most of the magic hides. Invoke the graph with thread refund-case-8842 today, and every checkpoint lands under that key. Invoke it again next Tuesday with the same thread ID and no fresh input, and LangGraph loads the latest checkpoint and continues from the exact node boundary where execution stopped. Different thread ID, different workflow instance, fully isolated state.

You can inspect a thread at any time without running it:

snapshot = graph.get_state(config)
print(snapshot.values)   # current state dict
print(snapshot.next)     # nodes that will run on resume, e.g. ('process',)

InMemorySaver is perfect for tests and prototyping, but it lives in the Python process, which defeats the entire purpose for real long-running work. For that we need a durable backend — and we will wire one up in a moment. First, let us make the graph actually pause.

Pausing Mid-Flight with interrupt

The interrupt function is how a node deliberately suspends the workflow. When a node calls interrupt(payload), LangGraph stops execution at that point, persists a checkpoint through your checkpointer, and returns control to the caller with the payload surfaced under a special __interrupt__ key. The process that started the run can now exit. Nothing is waiting, nothing is polling — the workflow simply exists as rows in your database until something resumes it.

Here is the refund graph extended with a human approval gate:

from langgraph.types import interrupt, Command

def human_approval(state: RefundState) -> Command:
    decision = interrupt({
        "question": "Approve this refund?",
        "analysis": state["analysis"],
        "amount": state["amount"],
    })
    if decision["approved"]:
        return Command(goto="process", update={"approved": True})
    return Command(goto="reject", update={"approved": False})

def reject_refund(state: RefundState) -> dict:
    return {"resolution": "Refund declined by reviewer"}

builder = StateGraph(RefundState)
builder.add_node("analyze", analyze_request)
builder.add_node("human_approval", human_approval)
builder.add_node("process", process_refund)
builder.add_node("reject", reject_refund)
builder.add_edge(START, "analyze")
builder.add_edge("analyze", "human_approval")
builder.add_edge("process", END)
builder.add_edge("reject", END)

When this graph runs, analyze completes, human_approval starts, hits the interrupt call, and the run stops. The returned payload — the question, the analysis, the amount — is exactly what your application shows the reviewer, whether that is a Slack message, an email with an approval link, or a row in an internal dashboard.

Two behaviors are worth burning into memory, because they are the source of most interrupt-related bugs.

First, interrupt works by raising a special exception internally. Code after the interrupt call in that node does not run when the graph pauses — it runs later, on resume.

Second, and more importantly: when the graph resumes, the interrupted node re-executes from its beginning, not from the interrupt line. The interrupt() call then returns the resume value instead of pausing again. This means any code placed before interrupt inside that node will run twice. If that code charges a card or sends an email, you have a duplicate side effect. The rule is simple: keep side effects out of the code path that precedes an interrupt, either by moving them to a separate node or by placing them after the interrupt call.

Resuming Days Later with Command

Fast-forward four days. The reviewer finally clicks Approve in your dashboard. Your web handler — a completely different process, possibly on a different machine, possibly running a newer deployment of your code — needs to wake the workflow up. It does so by invoking the same compiled graph, with the same thread ID, passing a Command with a resume value:

from langgraph.types import Command

config = {"configurable": {"thread_id": "refund-case-8842"}}

result = graph.invoke(
    Command(resume={"approved": True, "reviewer": "priya@company.com"}),
    config,
)
print(result["resolution"])  # "Refund issued"

Walk through what actually happens here, because it is the crux of the whole pattern. The graph sees a Command(resume=...) instead of fresh input, so it loads the latest checkpoint for refund-case-8842 from the checkpointer. The checkpoint says execution stopped inside human_approval. The node re-executes; this time interrupt does not pause — it returns the resume payload, {"approved": True, "reviewer": "priya@company.com"}. The node inspects the decision, returns Command(goto="process", update={"approved": True}), and the graph proceeds to issue the refund and run to completion. From the workflow's point of view, four days passed inside a single function call.

Notice what you did not have to write: no status column, no dispatch table mapping states to entry points, no reconstruction of conversation history, no special resume endpoint logic beyond "invoke with Command". The graph definition is the resume logic.

A few practical notes. The resume value can be anything serializable — a boolean, a dict of edits, a corrected draft the human rewrote. A single node can contain multiple interrupt calls, and LangGraph matches resume values to them by order within the node, which is another reason to keep interrupt-bearing nodes small and deterministic. And if you want a coarser tool, graph.invoke(None, config) resumes a graph that was paused with static breakpoints (interrupt_before=["node"] at compile time) without injecting any value — useful for simple "pause here, let me look, then continue" debugging flows, while interrupt remains the right tool for collecting real input.

You can also correct state before resuming. Suppose the reviewer noticed the amount was wrong:

graph.update_state(config, {"amount": 99.0})
result = graph.invoke(Command(resume={"approved": True}), config)

update_state writes a new checkpoint with the patched values, and the resumed run proceeds from that corrected snapshot.

Durable Persistence with a Postgres Checkpointer

Everything so far ran on InMemorySaver, which forgets on restart. For workflows that sleep for days, the checkpointer must be a real database. LangGraph ships first-party checkpointers for Postgres and SQLite as separate packages; Postgres is the standard choice for production.

pip install langgraph langgraph-checkpoint-postgres psycopg[binary,pool]

Wiring it in changes almost nothing about your graph code — which is exactly the point:

from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://app:secret@db.internal:5432/agents"

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()  # creates checkpoint tables; run once at deploy time

    graph = builder.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": "refund-case-8842"}}
    result = graph.invoke(
        {"request_id": "8842", "amount": 129.0, "approved": False},
        config,
    )
    # result["__interrupt__"] holds the approval question.
    # This process can now exit. The workflow is safe in Postgres.

setup() creates the checkpoint tables and must be called once before first use — a common first-run stumble is skipping it and hitting missing-relation errors. After that, every super-step of every thread is written to Postgres: the serialized channel values, the pending node schedule, and the metadata linking each checkpoint to its parent.

In an async web application — FastAPI is the typical host for this pattern — use the async variant with a connection pool that lives for the lifetime of the app:

from contextlib import asynccontextmanager
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

@asynccontextmanager
async def lifespan(app):
    async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
        app.state.graph = builder.compile(checkpointer=checkpointer)
        yield

# In the approval webhook handler, days later:
async def approve(case_id: str, approved: bool, request):
    graph = request.app.state.graph
    config = {"configurable": {"thread_id": f"refund-case-{case_id}"}}
    result = await graph.ainvoke(Command(resume={"approved": approved}), config)
    return {"resolution": result["resolution"]}

This is the full production shape of a LangGraph long running workflow: a stateless application tier that can scale, restart, and redeploy freely, with all workflow state externalized to Postgres. Any replica can resume any thread, because the thread lives in the database, not in a process.

One more knob matters for durability guarantees. invoke and ainvoke accept a durability argument: "async" (the default) writes checkpoints in the background while the next step begins, "sync" blocks until each checkpoint is committed before proceeding, and "exit" persists only when the run finishes or pauses. For workflows where a crash between steps must never lose a completed step — payments, anything with external side effects — run with durability="sync" and accept the small latency cost. For long chatty agent loops where the occasional re-executed step is harmless, the default is fine.

Surviving Crashes: Recovery and Idempotency

Human approvals are the planned kind of pause. The unplanned kind — an OOM kill, a node eviction, a network partition mid-run — is where checkpointing earns its keep twice over.

Because a checkpoint is written at every super-step boundary, a crash loses at most the work of the step that was in flight. Recovery is the same operation as resuming from an interrupt: invoke the graph with the thread ID and None as input, and it continues from the last durable checkpoint. You can build a small reaper that finds threads stuck mid-execution and re-drives them:

def recover_thread(graph, thread_id: str):
    config = {"configurable": {"thread_id": thread_id}}
    snapshot = graph.get_state(config)
    if snapshot.next:  # nodes still scheduled => run did not finish
        if snapshot.tasks and snapshot.tasks[0].interrupts:
            return "waiting_on_human"   # paused intentionally; leave it
        graph.invoke(None, config)      # crashed mid-run; re-drive it
        return "recovered"
    return "already_complete"

The subtlety is that the interrupted or crashed step re-executes from the top of its node. LangGraph replays successful writes where it can — results of completed nodes are not recomputed — but within a node, your code runs again. That makes idempotency inside nodes your responsibility, and there are three reliable tactics.

First, keep nodes small. A node that does one thing — one LLM call, one API call, one database write — has a tiny replay window and an obvious idempotency story.

Second, use idempotency keys on external side effects. If a node calls a payment API, derive the key from the thread ID and step so a replay becomes a no-op on the provider side.

Third, for expensive or non-repeatable work that must live alongside an interrupt, wrap it in a task. Task results are persisted independently, so on resume the task's cached result is returned instead of re-running the function:

from langgraph.func import task

@task
def send_approval_email(analysis: str) -> str:
    # Runs once; its return value is checkpointed and replayed on resume
    email_client.send(to="reviewer@company.com", body=analysis)
    return "sent"

def human_approval(state: RefundState) -> Command:
    send_approval_email(state["analysis"]).result()
    decision = interrupt({"question": "Approve?", "amount": state["amount"]})
    ...

With these habits, a pod eviction three steps into a nine-step workflow is a non-event: the reaper re-drives the thread, completed steps replay from checkpoints, and the in-flight step re-executes safely.

Time Travel, Auditing, and Forking Old Threads

Checkpoints do more than enable resumption — they are a complete execution history, and for workflows that span days that history becomes an audit log you get for free.

Every checkpoint for a thread is retrievable:

config = {"configurable": {"thread_id": "refund-case-8842"}}

for snapshot in graph.get_state_history(config):
    print(
        snapshot.config["configurable"]["checkpoint_id"],
        snapshot.next,
        snapshot.values.get("approved"),
    )

This answers questions your compliance team will eventually ask: what did the agent know when it recommended approval, what did the state look like before the human edited it, which step produced this value. Each snapshot carries the state values, the pending nodes, and a checkpoint_id you can address directly.

Addressing a specific checkpoint unlocks time travel. Pass a checkpoint_id in the config and invoke, and LangGraph forks the thread from that historical point — the original history is preserved, and a new branch of execution grows from the old snapshot:

past = {"configurable": {
    "thread_id": "refund-case-8842",
    "checkpoint_id": "1ef663ba-28fe-6528-8002-5a559208592c",
}}
# Re-run from that point, optionally with edits applied first
graph.update_state(past, {"amount": 79.0})
result = graph.invoke(None, past)

For long-running workflows this has very practical uses beyond debugging. A reviewer rejects a contract clause on day three; instead of restarting the whole pipeline, you fork from the checkpoint before drafting, patch the state with the reviewer's guidance, and re-run only the tail. An agent made a bad tool choice at step six of ten; you replay from step five with a corrected instruction. What-if analysis, regression testing against historical threads, and "redo from here" product features all fall out of the same primitive.

Two caveats. Histories grow — a chatty agent can produce hundreds of checkpoints per thread, so plan retention (more below). And forking re-executes nodes downstream of the fork point, so the same idempotency rules from the previous section apply.

Production Pitfalls Checklist

Teams shipping their first LangGraph long running workflows tend to hit the same walls. Here is the short list to review before launch.

  • Thread ID discipline. The thread ID is the join key between your business domain and the workflow engine. Derive it deterministically from the business entity (refund-{case_id}, onboarding-{customer_id}-{year}) rather than generating random UUIDs you then have to store and look up. Collisions silently merge two workflows into one thread; treat ID construction as carefully as a database primary key.
  • Checkpoint growth and TTL. Every super-step writes a checkpoint, and message-heavy states make each one large. Decide retention up front: delete thread data for completed workflows after a grace period (checkpointers expose thread deletion), or archive terminal snapshots elsewhere and purge history. Unbounded checkpoint tables are the most common source of surprise Postgres bills.
  • Code changes between pause and resume. A thread paused on Monday may resume on Friday under a new deployment. Renaming or removing a node that a pending checkpoint references will break resumption, and reshaping the state schema can leave old checkpoints missing keys. Keep state changes additive (new optional keys with defaults), keep node names stable, and drain or migrate in-flight threads before breaking changes — the same discipline you already apply to database migrations.
  • Serialization limits. State must serialize. Raw sockets, open file handles, and exotic objects in state will fail or bloat checkpoints. Store references (URLs, object-store keys, row IDs) instead of blobs.
  • Interrupt payloads are your UI contract. Whatever you pass to interrupt() is what the approval surface renders days later, when the original context is gone. Include everything the human needs to decide — and everything the resuming code needs to validate the decision.
  • Don't poll — notify. The graph pauses instantly, but a workflow nobody knows is paused waits forever. Pair every interrupt with an outbound notification (email, Slack, ticket) carrying a link that hits your resume endpoint, and add a scheduled sweep for threads paused longer than an SLA so approvals do not rot in silence.
  • Observability. Log thread ID on every invoke and resume, and emit a metric for paused-thread age. "How many workflows are waiting on humans right now, and how old is the oldest" is the first dashboard you will wish you had.

None of these are exotic; they are the standard operational concerns of any stateful system. The difference is that LangGraph gives you the hard parts — durable state, exact-position resumption, replay semantics — so the checklist stays operational rather than architectural.

From Pattern to Production Habit

Strip away the API details and the mental model is compact. A checkpointer turns your graph into a durable state machine: every step commits, so progress survives anything. A thread names one workflow instance, and the thread ID is all a future process needs to find it. An interrupt is a planned pause that ships a payload to a human; a Command(resume=...) is the reply that wakes the graph, days later, in whatever process happens to receive it. Crashes are just unplanned pauses recovered the same way, and the checkpoint history doubles as audit log and time machine. Once this clicks, "the approver is on vacation until Monday" stops being an architecture problem and becomes a row in Postgres patiently waiting for an update.

The refund example here is deliberately small, but the same skeleton scales to multi-agent research pipelines that pause between phases, document workflows that wait on uploads, and escalation chains with multiple sequential approvers — each is just more nodes, more interrupts, and the same checkpointer underneath.

If you want to go deeper — building multi-step approval graphs from scratch, streaming progress to a UI while a thread runs, structuring state for agents that live for weeks, and deploying checkpointed graphs behind real APIs — the LangGraph Tutorial course on teachyou.ai walks through all of it hands-on, from your first StateGraph to production-grade human-in-the-loop systems, with the persistence patterns from this article implemented end to end. Your agents are ready to work on human timescales; the course will get you there faster.