AI · Python · Architecture

LangGraph: what happens to your workflow when the server dies

Abstract cover: four workflow steps with a save marker under each, the third step struck by a crash and a second worker resuming it

Ana's refund is running. Look up the order, check the policy, charge the card, send the email.

In the middle of charging her card, the server dies. A deploy, an out-of-memory kill, it doesn't matter. A new server starts. Does it charge Ana's card again? Does it start over? Or does it know exactly where it was?

That question is what LangGraph is for. It's the runtime underneath every LangChain agent, and a framework in its own right for workflows that have state, wait for people, and must survive failures. Further down, we kill a real process and watch what happens. First, the workflow.

Hexcursion Studio Premieres soon

LangGraph Tutorial: Build Workflows That Survive a Crash

--days
--hours
--min
--sec
Prefer video? The walkthrough plays right here the moment it goes live.

How the code was run. No model is called anywhere in this post. Every node is plain Python, and every output is from a real run of LangGraph 1.2.12. Two of the demos kill a real operating-system process and restart it from a SQLite checkpoint. That's the point: LangGraph is a workflow runtime, and a model is just one thing a node can call.

Why draw a graph at all

If you've built a LangChain agent, you've already used LangGraph. That agent is a graph with two nodes, model and tools, and the model picks the next step.

A refund shouldn't be improvised. You know the steps in advance, so you draw them yourself, and a model becomes just one thing a node can call, in the one place you need judgment.

State, and nodes that return only what they change

State is a typed dictionary. A node is a function that gets the state and returns only the keys it changes:

class Ticket(TypedDict, total=False):
    order_id: int
    late_by: int
    fee: float
    reply: str

def lookup(state: Ticket) -> Ticket:
    o = ORDERS[state["order_id"]]
    return {"late_by": o["delivered"] - o["promised"], "fee": o["fee"]}

LangGraph merges that return value into the state. Nothing else is touched, which keeps every node a small, testable function.

Edges: a branch is an if, not a guess

A normal edge always goes the same way. A conditional edge calls a function to decide:

def route(state: Ticket) -> str:
    return "refund" if state["late_by"] > 0 else "decline"

builder.add_conditional_edges("lookup", route, ["refund", "decline"])
--- order 1042
{'lookup': {'late_by': 4, 'fee': 6.99}}
{'refund': {'reply': 'Refunded $6.99 — 4 days late.'}}
--- order 1043
{'lookup': {'late_by': -1, 'fee': 4.99}}
{'decline': {'reply': "Your order arrived on time, so shipping isn't refundable."}}

Order 1042 was four days late and went to refund. Order 1043 arrived a day early and went to decline. Every path the workflow can take is written down, and you can unit-test each one.

A node can also route itself by returning a Command, which updates the state and names the next node in one value:

def triage(s: S) -> Command[Literal["auto_refund", "manager"]]:
    if s["amount"] <= 20:
        return Command(update={"route": "auto"}, goto="auto_refund")
    return Command(update={"route": "manager"}, goto="manager")

A $6.99 refund went to auto_refund and $480 went to a manager. One gotcha: annotate the return type. Without the Literal, the graph still runs, but the diagram is wrong. In our test, the drawing showed triage → __end__ while the run actually went to the manager.

Reducers: how updates merge

Each key has a reducer, the rule for merging an update. With no reducer, the newest value wins. With one, updates combine:

class S(TypedDict):
    last_note: str                                   # no reducer: the newest value wins
    notes: Annotated[list[str], operator.add]        # reducer: lists are concatenated
{'last_note': 'checked policy', 'notes': ['ticket opened', 'looked up order', 'checked policy']}

And if two nodes run at the same time and write the same key with no reducer, LangGraph refuses:

InvalidUpdateError: At key 'v': Can receive only one value per step. Use an Annotated key to handle multiple values.

Any key written by parallel branches needs a reducer. For chat state, add_messages appends new messages and replaces a message whose ID already exists.

Checkpoints: the actual product

This is the feature that makes LangGraph different. Add a checkpointer, and the state is saved after every step under a thread_id. One thread is one workflow, one ticket, one order.

Four checkpoints saved along a run, start, lookup next, refund next and done; from the checkpoint before refund, an edited fee of 4.99 forks a second branch that ends in a refund of 4.99startlookup nextrefund nextdonerefund 6.99fee → 4.99forkrefund 4.99
Every step is a checkpoint. Edit one and continue, and the run forks while the original stays on the thread.
run 1: {'fee': 6.99, 'refund': 6.99}
  step  2 next=()             values={'fee': 6.99, 'refund': 6.99}
  step  1 next=('refund',)    values={'fee': 6.99}
  step  0 next=('lookup',)    values={}
  step -1 next=('__start__',) values={}
fork : {'fee': 4.99, 'refund': 4.99}
checkpoints on the thread now: 6

get_state_history returned every checkpoint. We took the one before refund, had a reviewer correct the fee with update_state, and continued. That's time travel, and it's how you replay a bad run from the step before it went wrong, without redoing the steps before it.

A store is the other kind of memory. A checkpointer remembers one thread, and a store remembers across threads. Three separate tickets from Ana wrote refund #1, #2, #3 into one ("customers", "ana") record. Use the checkpointer for where a workflow is, and the store for what you know about a customer.

Interrupts: pausing for a person

interrupt() stops the run and hands a question to your app. Command(resume=...) on the same thread continues it:

def approve(state: Ticket) -> Ticket:
    runs["approve"] += 1
    answer = interrupt({"question": f"Refund ${state['fee']} on order {state['order_id']}?"})
    return {"approved": answer == "yes"}

out = graph.invoke(Command(resume="yes"), cfg)
interrupt payload: {'question': 'Refund $6.99 on order 1042?'}
paused before: ('approve',) | refunds: []
reply: Refund sent. | refunds: [1042]
approve node ran 2 times

The node ran twice. On resume, LangGraph doesn't continue from the interrupt line. The docs say "the runtime restarts the entire node from the beginning", and this time interrupt() returns the answer. So anything before interrupt() happens twice: if that was an email, you sent two. Put side effects after the interrupt or in the next node, and keep code before it deterministic. Multiple interrupts in one node are matched by order, so a node that asks twice gets its answers back in sequence.

The functional API gives you a second tool. Wrap the side effect in a @task:

@task
def charge(order_id: int) -> str:
    calls["charge"] += 1
    return f"charged {order_id}"

@entrypoint(checkpointer=InMemorySaver())
def refund_flow(order_id: int) -> dict:
    receipt = charge(order_id).result()
    ok = interrupt(f"{receipt}. Send the confirmation email?")
    return {"receipt": receipt, "emailed": ok}
charge task ran 1 time(s)

The workflow re-ran from the top on resume, but the charge ran once. A finished task saves its result, and on replay LangGraph returns the saved result instead of running it again.

Failures, retries and timeouts

The payment API times out mid-run. Then we resume the thread with invoke(None, cfg):

run 1 failed: payment API timed out
after failure  → {'lookup': 1, 'charge_card': 1, 'email': 0} | next: ('charge_card',)
after resume   → {'lookup': 1, 'charge_card': 2, 'email': 1} | receipt: rcpt_77

lookup wasn't repeated, because its result was saved. charge_card ran again in full. That second run is the one to design for: if the first attempt charged the card before the connection dropped, the retry charges it again. Give the payment provider an idempotency key built from the thread and the step, and a repeat becomes harmless.

For short outages, put the retry on the node itself: RetryPolicy(max_attempts=5, retry_on=ConnectionError) failed twice, then returned {'rate': 1.08} after 3 attempts, and the graph never saw the failures. LangGraph 1.2 added a per-node timeout= (async nodes only) and an error_handler= that runs when retries are used up. In our test, a hanging carrier API was cut off at 0.2 seconds, twice, and the handler sent the ticket to a human instead of failing the run.

Parallel work is something you draw

Two edges out of START, and fraud_check and stock_check run in the same step. When the stock API failed, the fraud result was kept, and on resume only the stock check ran again:

runs: {'fraud_check': 1, 'stock_check': 1} | next: ('stock_check',)
after resume runs: {'fraud_check': 1, 'stock_check': 2}
results: ['fraud: clear', 'stock: 3 left', 'decision: ship']

When the number of branches depends on the data, return Send objects: one refund_one per order, run in parallel, and a list reducer collected ['refunded 1042', 'refunded 1043', 'refunded 1044'].

The real crash

Now, the question from the start. The workflow was the same four steps, lookup → check_policy → charge_card → email, saved to SQLite. Worker 1 was killed with os._exit(1) inside charge_card: no exception, no cleanup. Worker 2, a brand-new process, resumed the same thread:

=== durability='async'
  [worker-1] running lookup
  [worker-1] running check_policy
  [worker-1] running charge_card
  worker-1 killed (exit 1); checkpoints on disk: 1
  [worker-2] running lookup
  [worker-2] running check_policy
  [worker-2] running charge_card
  [worker-2] running email
=== durability='sync'
  [worker-1] running lookup
  [worker-1] running check_policy
  [worker-1] running charge_card
  worker-1 killed (exit 1); checkpoints on disk: 4
  [worker-2] running charge_card
  [worker-2] running email
ModeWhat LangGraph's source saysAfter the kill
"sync""persisted synchronously before the next step starts"4 checkpoints; resumed at charge_card
"async" (default)"persisted asynchronously while the next step executes"1 checkpoint (input only); started over
"exit""persisted only when the graph exits"0 checkpoints

Async is the default because it's faster: the run doesn't wait on the database between steps. The cost is that a hard kill loses whatever saves were still in flight. Our steps took microseconds, so almost nothing had landed. With slower steps more would have, but you can't count on it. An ordinary Python exception is different: even under async, it left 3 checkpoints with next pointing at the failing node.

And look closely: in both runs, charge_card ran twice. Once when the process died, once on resume. Durability decides where you restart. Idempotency decides whether the retry is safe. Ana needs both.

What it costs

The library and the checkpointers are MIT and free. You pay for your database, and checkpoints grow faster than you'd think. We ran a thread that adds one 2 KB message per turn, against SQLite:

TurnsCheckpointsBytes stored (default)Bytes stored (DeltaChannel)
1030326,28735,574
25751,943,02089,448
501507,642,132179,254

About 100 KB of messages cost 7.6 MB of checkpoints by default, because every checkpoint stores the whole list again. DeltaChannel, new and still beta in 1.2, stores only the new write and replays writes on read. It was about 43 times smaller, and all 50 messages came back. Its reducer must be batching-invariant, and its on-disk format isn't stable yet, so use it on long message histories and keep the default elsewhere. (Byte counts vary by a few bytes between runs.)

In production

Put a model only in the nodes that need judgment, like classifying the ticket, and keep the outer graph fixed: look up, check policy, approve, pay. A LangChain create_agent agent can itself be a node. Then:

Before you ship a workflow, check seven things: no side effects before interrupt(), idempotency keys on external calls, durability="sync" where a lost step costs money, a reducer on every parallel key, a real checkpointer, big data out of state, and schema discipline for state.

When to use it

Use it when the workflow has to survive time: waiting for a person, surviving a deploy, resuming after a failure without paying for the same work twice. Nothing else in the Python agent ecosystem gives you checkpointed pause and resume this directly, and every node is still a plain function.

Skip it for a single request nobody waits on, where a function is simpler. And for a model calling a few tools, start with LangChain's create_agent, which is this runtime already wired.

Your turn. Build Ana's refund as a graph: lookup → check_policy → approve → charge_card → email. Put the approval behind interrupt(), give the charge an idempotency key, and set durability="sync". Then kill the worker inside charge_card, resume from a second process, and prove the card was charged once. Almost nobody runs that test, and it's the one that finds the bugs.


Versions: langgraph 1.2.12, langgraph-checkpoint 4.2.0, langgraph-checkpoint-sqlite 3.1.1, Python 3.12, researched 29 September 2026. Sources: the LangGraph overview, interrupts and persistence docs, the release changelog, and the library source (langgraph/types.py, pregel/main.py) for the durability modes and defaults. Not verified: Postgres checkpointer behavior (only SQLite and in-memory were run), and how much survives under "async" with slow steps.

All postsReply by email

Keep reading

Next posts