DEV Community

Cover image for Why LangGraph State Disappears After Run Cancellation: Streaming, Checkpoints and Recovery
xn
xn

Posted on • Originally published at xbstack.com

Why LangGraph State Disappears After Run Cancellation: Streaming, Checkpoints and Recovery

A production-focused update based on a real project: Why does streamed LangGraph output disappear after Stop? A LangGraph 1.2.9 and SQLite experiment compares 16 streaming cases, …

Why LangGraph State Disappears After Run Cancellation: Streaming, Checkpoints and Recovery Consistency

A user sees half an answer, presses Stop, and then watches that answer disappear on the next refresh or message. It is tempting to blame the frontend. It is equally tempting to switch LangGraph from the default durability mode to sync and assume the problem is solved.

The experiment in this article points to a more precise boundary:

A stream event that has reached the UI is not necessarily part of the authoritative LangGraph checkpoint. The decisive question is not only whether durability is sync, async, or exit; it is whether the visible progress crossed a completed graph-step boundary and became a state update.

I ran a deterministic matrix on Python 3.11.15, LangGraph 1.2.9, and AsyncSqliteSaver. The fixture uses no model and no external API. It compares two graph shapes, four durability inputs, and two termination paths, producing sixteen streaming cases plus one interrupt()/resume case.

On normal completion, every combination produced six visible chunks and six checkpointed chunks. When the consumer closed the stream after the third visible chunk, all four durability modes on a single long-running node produced the same mismatch: UI 3, checkpoint 0. When the same progress was modeled as completed graph steps, all four cases restored 3 of 3 chunks. The interrupt experiment added a second result: prior completed state was preserved, but code before interrupt() ran twice because the interrupted node restarted from its beginning on resume.

This is not a model-quality article. It is a state-semantics article. The practical question is:

When a user cancels a streaming LangGraph run, how do we keep the last visible UI state, the recoverable graph state, and any external side effects consistent?

Test design, environment, and explicit boundaries

Using a real LLM would introduce provider buffering, token batch size, network variance, and model latency. The fixture instead streams six deterministic strings:

LangGraph | streams | visible | progress | before | checkpoint.
Enter fullscreen mode Exit fullscreen mode

There is a 60 ms delay between chunks. In the cancellation path, the consumer closes the async stream after the third visible update. That is a deliberately narrow model of a browser disconnect, a client that stops consuming, or an application that abandons a local run.

The recorded environment is:

Item Recorded value
Python 3.11.15
LangGraph 1.2.9
langgraph-checkpoint-sqlite 3.1.0
Checkpointer AsyncSqliteSaver
External model calls 0
External API calls 0
Stream format version="v2"
Stream modes custom and updates
Cancellation point after 3 visible chunks
Durability inputs default, sync, async, exit

The default case omits the durability argument. The current LangGraph reference documents the default as async: completed changes are persisted while the next step executes. sync persists completed changes before starting the next step. exit persists when the graph exits.

That wording matters. Durability controls when an existing graph-state change is written. It does not promise to convert arbitrary local variables or custom stream events inside an unfinished node into checkpointed state.

The three tasks were:

  1. Monolithic stream node — one node emits six custom events, then returns its state only after the loop finishes.
  2. Stepwise progress graph — each chunk is appended by a completed node execution, followed by a conditional edge back to the same node.
  3. Interrupt and resume — a prepare node returns an approval payload; an approval node records a prefix event, calls interrupt(), and resumes with the same thread ID and Command(resume=True).

The fixture does not test LangSmith Agent Server cancellation endpoints, disconnect_mode, a remote Postgres checkpointer, reverse-proxy disconnect behavior, or real provider token streams. Closing a local async iterator is not identical to every managed runtime. The experiment establishes a local runtime/checkpointer boundary that a production deployment must verify again in its own stack.

Test scope separating official documentation, official issues, the local fixture, and untested managed-runtime behavior

The official persistence model: checkpoints are super-step state

LangGraph’s persistence documentation describes checkpoints as thread state snapshots created across graph execution. Full StateSnapshot checkpoints are associated with super-step boundaries. The runtime can also persist per-task writes so that, when one task in a super-step succeeds and another fails, the successful task does not have to be recomputed. Those pending writes are useful for fault tolerance, but they are not the same as an arbitrary snapshot of every in-memory value at every line of a node.

Streaming serves a different purpose.

  • custom can expose progress emitted from inside a node through get_stream_writer().
  • messages can expose model tokens.
  • updates exposes state updates after graph work produces them.
  • checkpoints exposes checkpoint creation events.

A UI may receive a custom event before the node returns. At that moment, three facts can all be true:

  1. the node is still running;
  2. the browser has rendered several chunks;
  3. the graph state has not received those chunks as a node return value.

The checkpoint is the authoritative recovery source. Reusing a thread_id asks the checkpointer to load committed graph state. It does not ask the browser for the last text it happened to render.

Official issue #5672 reports the resulting product failure in LangGraph Platform/API: users see streamed content, cancel before the next checkpoint, and later resynchronize to an older backend state. The issue was originally filed against LangGraph 0.3.31. This article does not claim to reproduce the exact Agent Server implementation bug on 1.2.9. It reproduces the underlying local semantic boundary: a custom event emitted by an unfinished node is not automatically a state update merely because a consumer observed it.

Monolithic cancellation: the UI receives three chunks before the node returns, while the checkpoint remains at the previous graph boundary

Experiment 1: the UI saw three chunks and the checkpoint stored none

The monolithic node is intentionally simple:

async def long_streaming_node(state: StreamState) -> StreamState:
    writer = get_stream_writer()
    emitted = []

    for token in TOKENS:
        emitted.append(token)
        writer({
            "kind": "visible_chunk",
            "token": token,
            "visible_text": "".join(emitted),
        })
        await asyncio.sleep(0.06)

    return {
        "status": "completed",
        "emitted_chunks": emitted,
        "final_text": "".join(emitted),
        "step_index": len(emitted),
    }
Enter fullscreen mode Exit fullscreen mode

When the node completes normally, all four durability inputs converge on the same business result:

  • six chunks reached the consumer;
  • six chunks were available from get_state();
  • the final text matched;
  • no runtime error occurred.

The SQLite write pattern differed. Default, sync, and async each recorded three checkpoints and ten write rows. exit recorded one final checkpoint and no intermediate writes. These counts are diagnostic evidence for this fixture, not a throughput benchmark.

When the stream closed after the third custom event, every durability mode produced the same state mismatch:

Durability UI chunks Checkpoint chunks Missing Authoritative match
default 3 0 3 no
sync 3 0 3 no
async 3 0 3 no
exit 3 0 3 no

This does not mean SQLite wrote nothing. The default, sync, and async cases each contained two checkpoints and seven write rows. exit contained one checkpoint. What they did not contain was the node-local emitted list, because the node never reached its return statement.

That distinction invalidates a common fix:

“Use durability="sync" so partial tokens are never lost.”

sync can narrow the persistence window for a completed step. It cannot persist a state update that the unfinished node never returned. There is no state update for the checkpointer to save.

This is the first production lesson: before changing durability, identify whether the disappearing object is a stream event, a returned graph-state update, a pending write, or an external business record. They are not interchangeable.

Recorded matrix: normal completion converges at 6/6, monolithic cancellation ends at 3/0, and stepwise cancellation restores 3/3

Experiment 2: completed graph steps restored all three visible chunks

The second graph changes the state boundary rather than the storage parameter. Each node execution appends one chunk and returns:

async def durable_step(state: StreamState) -> StreamState:
    index = int(state.get("step_index", 0))
    chunks = list(state.get("emitted_chunks", []))
    chunks.append(TOKENS[index])

    return {
        "status": "running" if index + 1 < len(TOKENS) else "completed",
        "emitted_chunks": chunks,
        "final_text": "".join(chunks),
        "step_index": index + 1,
    }
Enter fullscreen mode Exit fullscreen mode

A conditional edge returns to durable_step until all six chunks exist. The consumer follows updates, so the visible representation now comes from a completed graph-state update rather than an arbitrary event emitted midway through a node.

Normal completion still produced six visible chunks and six checkpointed chunks for all durability inputs. The write cost increased:

  • default, sync, and async: eight checkpoints and thirty-five writes;
  • exit: one final checkpoint.

This is expected. A finer recovery boundary creates more persistent work. A production system should not create one graph node per token; the tiny steps exist only to make the semantic boundary measurable.

When the consumer closed after the third completed update, every case restored all three chunks:

Durability UI chunks Checkpoint chunks Missing Authoritative match
default 3 3 0 yes
sync 3 3 0 yes
async 3 3 0 yes
exit 3 3 0 yes

Default, sync, and async recorded four checkpoints and twenty-one writes. exit recorded one checkpoint and five writes.

The exit result needs careful wording. Closing the stream caused this graph execution to exit, and the runtime had an opportunity to persist the current state on that exit path. It does not prove that exit had stored every intermediate step while the graph was still running. It also does not prove what would happen if the process were killed before cleanup completed.

The stronger conclusion is independent of that nuance:

Durable partial progress requires an authoritative boundary. That boundary can be a completed graph step, an application-owned append-only event log, or a task record in a separate store. It cannot be merely “the browser received an event.”

Stepwise progress: each completed state update creates a recovery boundary, so cancellation returns to the last completed chunk

What sync, async, and exit actually change

The three modes describe write timing for completed changes.

sync

A completed step is persisted before the next step starts. This is the strongest choice at a critical workflow boundary: approval decisions, durable task handoff, or a transition immediately before an external write. The trade-off is direct storage latency on the execution path.

async

Completed changes are persisted while the next step executes. This is the documented default and often provides a useful balance between recovery and throughput. There is still a cleanup and flush window. Discussion on issue #5672 in 2026 points to cancellation-path cleanup and pending async persistence as an implementation concern. The fixture did not patch the runtime or kill the process during cleanup, so that community analysis remains a source clue rather than a locally proven result.

exit

State is persisted when the graph exits. It can be appropriate for short, deterministic work that can be restarted as a whole. It is a poor fit when each step is an audit fact that must survive a worker crash.

A practical selection table is more useful than declaring one mode “best”:

Workload Recommended design Reason
State immediately before high-risk approval sync and a separate node persistence confirmed before advancing
Ordinary multi-step agent default/async and idempotent nodes balanced persistence and throughput
Short deterministic calculation exit fewer intermediate writes
Token-level UI rendering stream plus application event log tokens are display events, not graph checkpoints
Long external tool progress durable task table and sequence survives page, worker, and runtime changes
Irreversible side effect idempotency key and domain database checkpointing is not a business transaction

When debugging a cancellation rollback, ask these questions in order:

  1. Was the disappearing object a stream event or graph state?
  2. Had the node returned before cancellation?
  3. Which source is used after refresh: browser memory, application event storage, or the checkpointer?
  4. Did runtime cleanup complete?
  5. Did an external side effect happen even though graph state did not advance?

Changing durability before answering those questions treats write timing as if it were state modeling.

Durability controls when completed state is persisted; it does not convert unfinished node-local progress into a checkpoint

interrupt() is a recoverable pause, not ordinary cancellation

Cancellation usually means “stop the current run.” interrupt() means “pause at a recoverable graph position and wait for external input.” Official documentation requires a checkpointer and a stable thread ID. Resuming with the same thread and Command(resume=...) provides the return value of the interrupt() call.

The fixture first completed a preparation node:

async def prepare(state):
    return {
        "prepared_payload": "draft-action-v1",
        "status": "prepared",
    }
Enter fullscreen mode Exit fullscreen mode

The approval node then recorded observable prefix and suffix events:

def approval(state):
    side_effect_events.append("before_interrupt")

    approved = interrupt({
        "question": "Approve draft-action-v1?",
        "request_id": state["request_id"],
        "prepared_payload": state["prepared_payload"],
    })

    side_effect_events.append("after_interrupt")
    return {
        "approved": bool(approved),
        "status": "approved" if approved else "rejected",
    }
Enter fullscreen mode Exit fullscreen mode

Before resume, the checkpoint contained prepared_payload: draft-action-v1 and the graph was positioned at the approval work. After Command(resume=True), the final state was approved.

The event counts expose the important behavior:

Event Count
before_interrupt 2
after_interrupt 1

LangGraph did not restore a frozen Python stack and continue at the next line. It restarted the interrupted node from its beginning, matched the supplied resume value to interrupt(), and then continued. The result matches the official interrupt rules.

That behavior turns prefix side effects into a production risk. A call that creates an order, sends an email, appends an audit record, or decrements inventory before interrupt() may run again on resume.

Safe patterns are:

  • move irreversible work after interrupt();
  • put it in a separate node with its own checkpoint boundary;
  • use a stable operation ID and an idempotent upsert;
  • check the authoritative external result before retrying.

Official issues #6792 and #7361 report additional resume/replay edge cases involving subgraphs or a specific checkpoint ID. They are not evidence from this local fixture, but they justify a release gate: nested subgraphs, multiple interrupts, and checkpoint-specific resume paths must be tested against the exact version and deployment used in production.

Interrupt/resume timeline: earlier state persists, but the interrupted node restarts and the prefix runs twice

Production repair: separate visible progress, recoverable state, and business truth

A cancellation model with only running and completed is too coarse. A user can request cancellation while a model is generating, a tool is executing, an external API has completed but the local result has not been committed, or async checkpointer cleanup is still running.

A more explicit state machine is:

queued
  -> running
  -> cancel_requested
      -> cancelled_confirmed
      -> completed_before_cancel
      -> externally_completed_local_pending
      -> cancel_failed
  -> failed
  -> completed
Enter fullscreen mode Exit fullscreen mode

cancel_requested represents user intent. It is not proof that execution stopped. externally_completed_local_pending is essential for payment, email, ticket, upload, or job systems: the external world changed, but local graph state may still be behind. Blindly replaying from the last checkpoint can duplicate the operation.

I use three persistence layers for this class of system.

1. UI stream

This layer optimizes latency. Every event should carry at least:

  • run_id
  • sequence
  • event_type
  • timestamp
  • safe payload or payload reference

The UI can render it optimistically. It should not silently promote the event to durable conversation history.

2. Application event ledger

This layer stores progress the product promises to retain. A minimal append-only shape is:

run_events(
  run_id,
  sequence,
  event_type,
  payload_hash,
  safe_payload,
  created_at,
  UNIQUE(run_id, sequence)
)
Enter fullscreen mode Exit fullscreen mode

It does not have to write every token. A system can batch every 200–500 ms, store sentence boundaries, record tool phases, or persist a partial message object. Its job is to reconstruct what the user was shown after page reload or device change.

3. LangGraph checkpoint

This layer stores recoverable graph state, next nodes, task writes, and thread history. It is a workflow recovery mechanism. It should not be forced to contain every animation or token, and it should not replace an order, approval, or task database.

A production cancellation flow can then be:

  1. The client sends a cancel request containing run_id; it does not only close the SSE connection.
  2. The backend atomically records cancel_requested, who requested it, and when.
  3. The runtime stops cancellable work. Non-cancellable external work enters reconciliation.
  4. The application event ledger commits its last confirmed sequence.
  5. Completed graph steps persist according to durability.
  6. The backend sets a confirmed final status.
  7. Reload merges authoritative graph state with the event ledger by run and sequence.

Every layer needs idempotency. run_id + sequence deduplicates visible events. operation_id deduplicates external effects. Recovery checks the domain database before retrying a tool.

Production cancellation state machine separating requested cancellation, confirmed stop, external completion, and local persistence

Do not turn every token into a graph node

The 3/3 stepwise result is not a recommendation to checkpoint every token.

In the recorded fixture, default, sync, and async normal completion produced:

  • monolithic graph: three checkpoints and ten writes;
  • stepwise graph: eight checkpoints and thirty-five writes.

Those counts only describe this deterministic local fixture, not production latency or cost. They still reveal the direction of the trade-off: a finer recovery boundary usually creates more serialization and storage work.

Good graph boundaries carry business meaning:

  • a plan is accepted;
  • a tool call finishes;
  • a batch of document pages is parsed;
  • an approval package is ready;
  • an external object ID is obtained;
  • a retryable shard completes.

Poor graph boundaries are display details:

  • every token;
  • animation percentage;
  • arbitrary character chunks;
  • transient UI state that can be derived later.

For long text generation, tokens can stay on the stream. An application event ledger can persist paragraphs or a partial message every bounded interval. The graph state receives the complete message when the node finishes. On cancellation, the partial object can be labeled aborted_partial rather than disappearing or pretending to be a final answer.

For a long tool task, let the tool own a durable task_id and stage status. The graph stores the task ID, last confirmed stage, and recovery policy. The page reads progress from the task API. A graph worker restart no longer erases progress that existed only in a node’s local variables.

Regression tests that prove the repair

A repair is incomplete if it only passes the normal completion path. At minimum, automate four scenarios and assert both the last visible sequence and the last authoritative backend sequence.

Natural completion

Six progress events are emitted. The UI shows six. The event ledger stores six. After the node finishes, graph state contains the complete result. This protects the ordinary path from cancellation-specific changes.

Cancellation at a fixed sequence

After the third event, send a real cancel request, wait for a confirmed terminal state, create a fresh client, and reload the same thread. The result must match product policy: either preserve three events and label them partial, or deliberately discard them and remove them before presenting a stable state. “Show three now, silently lose them after refresh” is not acceptable.

Cancellation racing an external side effect

Complete the external operation immediately before and immediately after the cancel request. Assert that the same operation_id produces one order, one ticket, one email, or one file object. The important assertion is in the external system, not only the graph output.

Worker/process failure

After one meaningful business step, terminate the worker process and restart it. Reuse the same thread and compare sync, default async, and exit. Record the last recoverable step rather than inferring it from documentation.

Logs should connect five identifiers:

  • thread_id — long-lived workflow;
  • run_id — one execution attempt;
  • sequence — visible event ordering;
  • checkpoint_id — recoverable graph snapshot;
  • operation_id — external side effect.

Useful production metrics include:

  • cancel request to confirmed stop latency;
  • UI sequence minus checkpoint/event-ledger sequence;
  • partial-message recovery success;
  • duplicate side-effect prevention count;
  • nodes re-executed after resume.

A low cancellation count does not prove consistency. A stable zero or explainable value for these divergence metrics is much stronger evidence.

Failures, limitations, and claims this test does not support

The experiment intentionally retains failure evidence.

First, all four monolithic cancellation cases failed the consistency test: UI 3, checkpoint 0. This is the central negative result.

Second, the first regression-test loader dynamically imported the experiment without registering the module in sys.modules. Python 3.11 dataclass type resolution failed. Registering the module fixed the harness, and the semantic test then passed. This was a test-harness defect, not a LangGraph defect.

Third, several orphaned Astro development processes had exhausted system file descriptors, causing the first isolated installation attempt to fail with Too many open files in system. Stopping those project processes allowed the clean Python 3.11 environment to install. That environmental incident is excluded from the LangGraph conclusions.

The fixture also does not test:

  • Agent Server cancellation endpoints;
  • disconnect_mode="cancel" versus "continue";
  • Postgres checkpointer behavior under process death;
  • parallel pending writes within one super-step;
  • every nested-subgraph interrupt path;
  • provider-specific token streaming and abort signals;
  • a reverse proxy disconnect while backend execution continues;
  • durability performance, throughput, or cost.

It would therefore be inaccurate to say “LangGraph 1.2.9 still has exactly issue #5672” or “stepwise nodes solve every cancellation problem.” The supported claim is narrower:

On the local LangGraph 1.2.9 runtime, the experiment reproduces the underlying state boundary behind the same user-visible rollback: unfinished in-node stream output is not checkpointed state, while completed graph-step updates are recoverable.

Recorded experiment and regression evidence: seventeen cases, persisted SQLite results, and a passing unittest

Final decision: never let a stream impersonate a database

For a personal demo where users can regenerate an answer, discarding an unfinished response may be acceptable. The UI should make that policy explicit and remove partial content consistently.

For customer support, research, finance, approvals, long-running tools, or any auditable workflow, the UI and backend cannot disagree about what happened. Such systems need:

  • an explicit distinction between stream events, graph state, and business facts;
  • state updates at meaningful business boundaries;
  • an application event ledger for visible progress the product promises to retain;
  • stable thread, run, sequence, checkpoint, and operation identifiers;
  • a cancellation confirmation state machine;
  • idempotent side effects around interrupts and retries;
  • real disconnect, worker-restart, and same-thread regression tests.

The experiment’s most useful result is not a winning durability parameter. It is a clean separation of responsibilities:

Streaming helps users see progress quickly. Checkpoints let the graph recover. Domain storage proves what changed in the outside world. The three layers may reference one another, but none can safely impersonate the others.

Before implementing “Stop generation,” decide what must survive: a visible partial message, recoverable workflow state, or an external business fact. Consistency becomes tractable only after those three objects have explicit owners.

FAQ

Does durability="sync" prevent state loss on cancellation?

It protects completed graph-step changes by persisting them before the next step. It cannot save node-local progress that has been streamed but not returned as a state update. In all four monolithic cancellation cases, the UI showed three chunks and the checkpoint held zero.

Does interrupt() resume at the exact Python line?

No. The interrupted node restarts from its beginning. Code before interrupt() runs again, so side effects must be idempotent, moved after the interrupt, or isolated in a separate node.

Should every token be checkpointed?

Usually not. Stream tokens for latency, persist paragraphs or partial messages in an application event ledger, and write the complete message to graph state when the node finishes.

Why did exit preserve three chunks in the stepwise cancellation test?

Closing the iterator caused the graph execution to exit, giving the runtime an opportunity to persist the current state. It does not mean every step had already been persisted during execution, and it does not establish behavior under hard process termination.

Is local browser storage enough for partial responses?

It can mask a rollback in one browser, but it does not survive every reload, device change, or multi-instance synchronization path. If the product promises that visible partial content will remain, persist it in a backend event ledger.

Official sources and related XBSTACK guides


Canonical article on XBSTACK:https://www.xbstack.com/en/ai/langgraph-cancel-run-streaming-checkpoint-state-loss/?utm_source=devto&utm_medium=community&utm_campaign=langgraph_cancel_checkpoint_consistency&utm_content=langgraph-cancel-run-streaming-checkpoint-state-loss&ref=devto

标签:#AI #SoftwareEngineering #DeveloperTools #LangGraph #Streaming

Top comments (0)