Durable execution is not a LangGraph feature vs a Temporal feature — the correct 2026 pattern is a reasoning graph on top of a durable runtime, and the seam between them is where teams get it wrong.
A LangGraph checkpointer saves state between nodes. Temporal saves state within a workflow function. Confuse the two and your 30-minute research agent crashes 27 minutes in with nothing to resume from. The 2026 pattern that ships is "reasoning graph on top of durable runtime": LangGraph orchestrates the reasoning shape, Temporal owns the durability guarantee. Cordum's engineering post benchmarked plain LangGraph loops failing at ~10k items where Temporal-backed executions kept going. The seam design — which side owns retries, which side owns idempotency, where the checkpoint boundary sits — is the whole game.
Two flavors of durability, and why they don't substitute for each other.
Both LangGraph and Temporal will tell you they give you "durable execution," and both are telling the truth about a different thing. LangGraph's durability is a checkpointer: after each node in the graph runs, the framework serializes the graph state and writes it to a store (SQLite in dev, Postgres/Redis in prod). If the process dies between nodes, you can resume from the last checkpoint. Temporal's durability is a workflow function: every await inside a Temporal workflow is a durable persistence point, so if the worker dies mid-await, Temporal replays the function up to that await from history and continues.
The word "checkpoint" hides a factor-of-a-thousand difference in granularity. A LangGraph node is a whole reasoning step — plan, call three tools, summarize — and the checkpoint fires after the node returns. If your node is a for-loop over 10,000 items and it crashes at item 8,742, the resumed run restarts the node from item 0, because the framework only knows about node boundaries. A Temporal workflow function calling an activity per item checkpoints after each activity, so a crash at 8,742 resumes at 8,743. Neither is "wrong"; they are answering different questions about where you're willing to lose work.
The reason this matters in 2026 is that agents got longer. A research agent that used to take five minutes now takes forty. A batch enrichment job that used to touch a hundred rows now touches ten thousand. At those sizes, the difference between node-level and activity-level durability is the difference between shipping and not. The mistake is to pick a side. The pattern that works is to layer the two: LangGraph on top for reasoning shape, Temporal underneath for the durable execution guarantee.
Where LangGraph checkpoints, where Temporal checkpoints.
Draw the picture and the difference stops being confusing. A LangGraph run is a directed graph of nodes; the checkpointer writes to storage after each node's return value is available. Between nodes: durable. Inside a node: whatever the node's own code does, on its own. If a node calls three HTTP APIs, updates a database, and returns, and it crashes after the two HTTP calls but before the database update, the resumed run has none of that partial progress — it starts the node over.
A Temporal run is a workflow function whose every await point is a durable persistence event; the framework records every activity invocation and every activity result to a history log. Between awaits: durable. On resume, Temporal re-executes the workflow function from the top and, for each await it has already recorded, replays the recorded result rather than actually calling the activity. Determinism is what makes this work — the workflow function itself may not do any I/O directly, only through activities, and it may not use non-deterministic operations (real time, random numbers) without going through Temporal's deterministic APIs.
The concrete failure a mixed-up team ships: they wrap a LangGraph agent inside a Temporal workflow, then let the LangGraph node body do its own HTTP calls, own retries, own timeouts. Temporal has no idea any of that happened — from its perspective the workflow just called one activity ("run the LangGraph node") and got a result. The activity took 27 minutes and crashed; on retry, the whole 27 minutes runs again. The durability guarantee was on the wrong side of the seam.
The 10k-item cliff, and what actually breaks.
Cordum's engineering write-up benchmarked plain LangGraph loops against Temporal-backed executions on a batch enrichment task — the shape where an agent needs to plan once, then process a list of items with a tool call per item. Below about a thousand items, both are fine. Around 5k the LangGraph run gets slow but works. At 10k the LangGraph loop falls over, and the failure mode is not "the code raises an exception" — it is that the graph state, which includes the accumulated results, has grown to the point where the serialize / deserialize cost at each checkpoint exceeds the useful work done by each node. The framework spends most of its time writing state, not doing steps. Temporal-backed executions at the same size stay linear because Temporal's history log is append-only and streams items past the state — the state itself does not grow with the item count.
This is not a bug in LangGraph; it is what checkpointer-between-nodes semantics cost you when the "state" is a growing list. The fix is not "tune the checkpointer" — it is to move the loop out of the graph. Instead of a node that iterates 10k items internally, the graph plans the loop and dispatches to a Temporal activity per item (or per batch of items). Each activity is small, its result goes into the durable history rather than into a growing in-memory list, and the graph state stays small.
The same shape shows up in a research agent that reads a hundred documents. Do it inside one LangGraph node and every checkpoint ships all hundred document contents. Do it as one activity per document under Temporal and each activity's result is a fixed-size record in history. The heuristic: any time a "step" in your reasoning has a fanout larger than about 100, the loop wants to live outside the graph, and the graph wants to see only the summary of the batch.
The pattern that ships: reasoning graph on top of durable runtime.
Once you accept that both frameworks are right about different things, the pattern that stops causing problems is the same regardless of which SDKs you use. The reasoning shape — plan, call tools, reflect, replan — lives in LangGraph (or a similar orchestration library). The durability guarantee — retry, timeout, resume — lives in Temporal (or a similar runtime: Restate, Inngest, or DBOS all fit the shape). The two meet in exactly one place: each LangGraph node body is a Temporal activity.
The mechanical version. You write a Temporal workflow function that instantiates a LangGraph StateGraph. Each node in the graph, instead of running its work inline, submits an activity to Temporal. The activity does the actual work — the model call, the HTTP request, the database write. When the activity returns, Temporal has durably recorded the result; when the node returns, LangGraph has durably checkpointed the graph state. Both durabilities are now stacked, and the seam between them is the activity boundary. This aligns naturally with the plan-and-execute shape where the planner is a graph node and each executor step is an activity.
# workflow.py — Temporal workflow calling LangGraph nodes as activities from temporalio import workflow, activity from langgraph.graph import StateGraph from datetime import timedelta @activity.defn async def plan_node(state: dict) -> dict: # actual model call lives here, not inside the graph node body return {"plan": await llm.plan(state)} @activity.defn async def tool_node(state: dict, item: dict) -> dict: return {"result": await tool.run(item, key=state["idem_key"])} @workflow.defn class ResearchAgent: @workflow.run async def run(self, task: dict) -> dict: state = await workflow.execute_activity( plan_node, task, start_to_close_timeout=timedelta(minutes=2)) # fanout is Temporal's job, not the graph's results = await asyncio.gather(*[ workflow.execute_activity( tool_node, state, item, start_to_close_timeout=timedelta(seconds=30), retry_policy=RetryPolicy(maximum_attempts=5)) for item in task["items"]]) return {"summary": results}
What is doing work here: the LangGraph reasoning shape is preserved (plan then execute), but the graph nodes are activities, so Temporal owns the retries and the state persistence. The workflow function itself is a thin coordinator. If you kill the worker mid-fanout, Temporal resumes with the activities that already returned recorded in history and only re-issues the ones that hadn't started.
Idempotency and retries at the seam.
The seam has two rules that are non-negotiable, and both come from the same observation: the runtime will retry activities, and it will do so silently. Rule one: retries are owned by the runtime, not by the tool. Do not put retry loops inside your tool code when a durable runtime already gives you retry policies. You will get retries on top of retries — a burst that hits your downstream API rate limit within seconds and makes debugging painful. Configure Temporal's RetryPolicy (or the equivalent) on the activity and remove the ad-hoc retry from the tool. If the tool is called through a general error-recovery loop in the agent, make sure the loop treats "activity failed after Temporal's retries" as a distinct signal, not as another retryable error.
Rule two: idempotency is owned by the tool, not by the runtime. The runtime cannot know that "charge $12 to card X" is not safe to call twice; the tool has to know. Every side-effectful activity should accept an idempotency key that Temporal supplies (the workflow ID plus the activity attempt ID is a standard pattern) and use it as a dedupe token against the downstream system — Stripe idempotency-key, database ON CONFLICT, whatever the downstream supports. If the runtime retries the activity, the second call should see the first call's result and short-circuit, not repeat the side effect.
The picture of what a well-formed seam looks like:
workflow runtime tool
| | |
| execute_activity(charge) --> | |
| | attempt 1, idem=WF-A1 ----->|
| | | charge OK, store idem
| | <----- result recorded -----|
| | (worker crash here) |
| | replay from history |
| | attempt 2, idem=WF-A1 ----->|
| | | idem match: return prior
| | <----- same result ---------|
| <---- durable result ------- | |
Notice what the workflow function never sees: the crash, the retry, the second call. From the workflow's perspective, the activity ran once and returned once. That is the durability contract. Break either rule — retry inside the tool, or forget the idempotency key — and you get double charges, duplicate database writes, and traces that lie about what happened.
When you don't need durability.
The Temporal-underneath pattern earns its complexity budget only when the runs are long, expensive, or side-effectful enough that losing them costs real money or user trust. For interactive chats under five minutes with no meaningful side effects — the assistant answers, the user reads, the session ends — the framework overhead is dead weight. A LangGraph checkpointer alone (or even in-memory state) is fine, because a crash means the user hits retry and lives.
Latency-sensitive user-facing loops are the other case where you skip it. Every extra hop through a durable runtime is milliseconds; a voice agent or a UI streaming loop that has to feel instant cannot afford them on the hot path. The right answer there is often to run the fast path in-process and only escalate to a durable workflow if the request turns into something long-running (a research task, a batch job, a multi-step transaction). If you use the framework's abstractions to make that escalation seamless, the durability is available when you need it and out of the way when you don't.
The pattern to steal, if you skip only one thing: even without a full Temporal setup, put an idempotency key on every side-effectful tool call from day one. It costs nothing on the happy path and is the difference between a graceful recovery and a compensating transaction if you ever add durability later. That is the seam preparing itself for a runtime that isn't there yet.