Here is the deploy that quietly loses work. Your agent service is running a long task — the model has planned six steps, called three tools, and is reasoning about the fourth. You push a new version. Kubernetes sends the old pod a SIGTERM, waits its grace period, and then SIGKILLs it. The run was mid-node when the process died, so everything since the last checkpoint is gone. The user sees a task that started and never finished, and your logs show nothing because nothing failed — you shot it.

LangGraph 1.2 (May 2026) added the piece that fixes this: cooperative graceful shutdown. It's small, and — like per-node timeouts — the whole game is knowing which stopping mechanism you actually want.

The 30-second answer#

Create a RunControl, pass it as control= to your invoke/stream call, and call request_drain() from your SIGTERM handler. The run finishes the current superstep, checkpoints, and raises GraphDrained.

import signal
from langgraph.types import RunControl

control = RunControl()
signal.signal(signal.SIGTERM, lambda *_: control.request_drain())

try:
    result = graph.invoke(
        state,
        config={"configurable": {"thread_id": thread_id}},
        control=control,
    )
except GraphDrained:
    # the run stopped at a clean boundary and checkpointed itself
    log.info("drained %s — resumable", thread_id)

That's it on the shutdown side. But a drain is only half of a zero-downtime deploy; the other half is making the checkpoint outlive the process.

Three ways a run stops, and they are not the same#

The reason drain matters is that the alternatives quietly lose different amounts of work.

A crash or SIGKILL stops anywhere. The process dies mid-node, and you're left with whatever your checkpointer last wrote — potentially several tool calls ago. This is the failure case; nobody wants it, it's just the default when you do nothing.

A node timeout stops mid-node, on purpose. It kills one stuck attempt, clears that attempt's writes so partial state doesn't leak, and hands off to the retry policy. That's exactly right for a hung API call — and exactly wrong for a deploy, because it's aimed at one bad step, not the orderly stopping of a healthy run.

A graceful drain stops at a superstep boundary. A superstep is one tick of the graph — the set of nodes that run before state is committed — and it's the only point where graph state is consistent. Mid-node, writes haven't landed yet. request_drain() lets the current superstep finish, commits a checkpoint at that clean boundary, and only then raises GraphDrained. Nothing in flight is thrown away.

A timeout asks "is this one step stuck?" A drain asks "can we stop the whole run somewhere safe?" A deploy needs the second question, and only drain answers it.

Drain saves a checkpoint. A durable checkpointer keeps it.#

Here's the mistake that makes graceful shutdown look broken: draining with an in-memory checkpointer. The drain dutifully writes a checkpoint, the process exits, and the checkpoint dies with it. You did everything right and still lost the run.

Graceful shutdown and durable state are two halves of one feature. The drain produces a resume point; a persistent checkpointer keyed by thread_id is what lets a different process pick it up. Wire the durable backend first — this is the same Postgres-vs-Redis choice you'd make for resuming a crashed run — then layer drain on top.

from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string(DATABASE_URL)
graph = builder.compile(checkpointer=checkpointer)

Resuming on the new pod#

Resuming a drained run is the same move as resuming a crashed one: re-invoke the graph with the same thread_id and input=None. LangGraph loads that thread's last checkpoint and continues from the boundary where the drain stopped.

# on the newly deployed instance
for thread_id in drained_thread_ids:
    graph.invoke(
        None,                                            # continue, don't restart
        config={"configurable": {"thread_id": thread_id}},
    )

The one operational detail people miss: something has to carry the list of drained thread_ids from the old process to the new one. Write them to the same store as your checkpoints (a draining table, a Redis set) as each GraphDrained fires, and have the new pod's startup drain-and-resume that set. Don't rely on the exiting process to hand them off in memory — it's about to be gone.

The checklist#