Skip to content

Chapter 7: Memory & Checkpointing (Postgres Checkpointers)

Theoretical Foundations

In our previous exploration of LangGraph.js, we established the fundamental architecture of an autonomous agent as a graph. We defined nodes as computational units (like LLM calls or tool executions) and edges as the pathways of control flow. We saw how an agent could make decisions, branch, and loop, creating a dynamic execution path. However, this graph existed purely in the volatile memory of a single runtime session. If the server crashed, the user refreshed their browser, or the process was terminated for any reason, that entire intricate state—the conversation history, the accumulated data from tool calls, the current position in the workflow—would vanish into the ether.

This is where the concept of Memory & Checkpointing becomes not just an optimization, but the very foundation of a robust, production-ready agent system. At its heart, checkpointing is the mechanism of serializing the entire state of a LangGraph execution at a specific moment in time and persisting it to a durable storage medium. This allows the system to pause, resume, or even rewind its execution, creating the illusion of continuous, uninterrupted operation.

Think of an agent's execution as a complex video game. Without checkpointing, every time you close the game, you lose all your progress and must start from the very beginning. With checkpointing, the game automatically saves your progress (your character's location, inventory, and completed quests) to your hard drive. You can turn off the console, come back tomorrow, and continue exactly where you left off. In our context, the "game" is the agent's workflow, the "save file" is the serialized state in the database, and the "hard drive" is PostgreSQL.

The "Why": Durability, Debuggability, and Stateful Workflows

The necessity for persistent memory arises from three critical operational requirements:

  1. Durability and Fault Tolerance: In any distributed or long-running system, failures are inevitable. A server might reboot, a container could be terminated, or a network partition might occur. Without a checkpointing mechanism, any in-progress agent task would be lost, leading to a poor user experience and potential data loss. By persisting state, we ensure that the agent can recover from failure and resume its task without the user even being aware of the interruption. This is analogous to a distributed transaction in a microservices architecture, where a message queue ensures that a transaction is completed even if a service temporarily goes offline.

  2. Debuggability and Time-Travel: One of the most powerful features enabled by checkpointing is the ability to "time-travel" through an agent's execution. By saving the state at every step (or configurable intervals), we can not only resume from the latest point but also rewind to any previous checkpoint. This is invaluable for debugging complex, multi-agent workflows. Imagine a scenario where an agent makes a series of decisions leading to an incorrect outcome. Instead of re-running the entire process from scratch, a developer can load a previous checkpoint, inspect the exact state (the messages, the data, the graph's position), and perhaps alter the next step's logic to observe a different outcome. This transforms debugging from a static log analysis into an interactive, stateful investigation.

  3. Long-Term Conversational Memory: For a chatbot or a personal assistant to be truly useful, it must remember past interactions beyond the immediate session. A simple in-memory array of messages is insufficient. Checkpointing allows us to persist the entire conversation history. When a user returns after hours or days, the agent can load the last saved state, re-establish the full context of the conversation, and provide a seamless, continuous experience. This moves the agent from a stateless tool to a stateful companion.

The "How": LangGraph's Checkpointers and Postgres

LangGraph.js abstracts the checkpointing process through a CheckpointSaver interface. This interface defines the methods for saving, loading, and listing checkpoints. The implementation of this interface determines where and how the state is stored. The state itself is a well-defined object that includes: * values: The current data payload of the graph. * next: A list of nodes to be executed next. * config: The configuration used for this run. * metadata: Information about the checkpoint, such as the timestamp and source.

For this chapter, we focus on the PostgresSaver. This is a concrete implementation of the CheckpointSaver that uses a PostgreSQL database as its backend. It leverages a specific table schema to store checkpoint data. When a graph is executed with a PostgresSaver, the LangGraph runtime will automatically call the save method at the end of each superstep (a complete cycle of node executions). The state object is serialized (typically into a JSON format) and stored in the database alongside a unique checkpoint ID, the thread ID (which groups related runs), and version information.

Analogy: The Web Development State Management Paradigm

To understand this concept from a familiar web development perspective, let's draw an analogy between a LangGraph agent and a modern single-page application (SPA) using a state management library like Redux or Zustand.

  • The Agent's Graph State = The Global Store: In an SPA, the entire application's state (user data, UI state, form inputs) is often held in a central store. This is analogous to the values object within a LangGraph checkpoint. It represents the "single source of truth" for the application's current condition.

  • Checkpointing = State Persistence to LocalStorage/IndexedDB: When you want to persist a user's form progress or settings across browser sessions, you serialize the Redux store and save it to localStorage. The PostgresSaver performs the exact same function. It takes the entire graph state, serializes it, and writes it to a PostgreSQL table. The thread_id in the checkpoint is like a key in localStorage, allowing you to have multiple distinct "sessions" or "users" saved separately.

  • Resuming a Workflow = Rehydrating the Store on Page Load: When a user returns to your SPA, your application code checks localStorage for a saved state. If found, it "rehydrates" the global store by parsing the JSON and populating the state management library. The application then renders based on this restored state, appearing exactly as the user left it. Similarly, when an agent run is resumed, LangGraph queries the PostgresSaver for the latest checkpoint associated with a thread_id, deserializes the state, and reconstructs the graph's execution context, allowing it to continue from the exact node it was on.

  • Time-Travel Debugging = Redux DevTools: The Redux DevTools extension allows you to see a timeline of every state change and "time-travel" by jumping to a previous state. This is precisely what checkpointing enables for agents. By storing a history of checkpoints, we can query the database for a specific point in time and load that state, effectively rewinding the agent's execution to debug its behavior.

Under the Hood: The Checkpointing Flow

Let's dissect the lifecycle of a checkpoint in a LangGraph.js application using PostgresSaver.

  1. Initialization: The developer instantiates a PostgresSaver by providing a connection pool to a PostgreSQL database. This connection pool handles the efficient management of database connections. The PostgresSaver is then passed as an argument when creating the compiled graph's .stream() or .invoke() method.

  2. First Execution (No Previous State): When the agent is invoked for the first time with a specific thread_id, LangGraph begins execution. Since no checkpoint exists for this thread_id, the graph starts from its entryPoint. As the graph progresses through its nodes and edges, the state is modified in memory. At the end of each superstep, the PostgresSaver.save() method is called. It takes the current state object, serializes it, and executes an INSERT statement into a checkpoints table in PostgreSQL. This table is designed to store the checkpoint as a JSONB column for efficient querying and indexing.

  3. Subsequent Execution (Resuming): When the agent is invoked again with the same thread_id, the process changes. Before the graph begins, LangGraph calls PostgresSaver.list() to find all checkpoints for that thread. It typically selects the latest one. The load() method is then called with the checkpoint ID. The PostgresSaver retrieves the serialized state from the database, deserializes it back into a JavaScript object, and passes it to the graph's runtime. The runtime uses this state to determine the next nodes to execute, effectively resuming the workflow from where it left off.

  4. Time-Travel (Loading a Specific Checkpoint): For debugging, a developer can explicitly request to load a checkpoint from a specific point in time. They would call PostgresSaver.list() and filter the results by timestamp or version number. Selecting a specific checkpoint ID and passing it to the load() method allows the agent to be "reset" to that exact historical state, from which it can then proceed.

The Role of pgvector and Vector Stores (A Conceptual Bridge)

While pgvector is not directly involved in the checkpointing mechanism of LangGraph's state, it plays a parallel and crucial role in the broader context of agent memory. A checkpoint saves the procedural state of the agent—where it is in its workflow. However, an agent also needs semantic memory—the ability to recall relevant information from a vast knowledge base.

This is where vector stores come in. An agent might use a vector store (like one powered by pgvector in Supabase) to store documents, past conversations, or facts. When the agent needs to retrieve information, it converts the query into an embedding (a vector) and performs a similarity search against the stored vectors.

Analogy: If the PostgresSaver checkpoint is the agent's short-term procedural memory (its current position in a task, like remembering the current page in a book), then the vector store is its long-term semantic memory (its knowledge of all the concepts in the entire library). When the agent is restored from a checkpoint, it knows where it left off in the conversation (procedural memory) and can then query its vector store for relevant information to continue the dialogue (semantic memory). They work in tandem to create a truly intelligent and context-aware system.

Visualizing the Checkpointing Flow

The following diagram illustrates the flow of execution and checkpointing for a single agent run.

This diagram illustrates the sequential flow of an agent's execution, highlighting how checkpointing is used to save the agent's state at critical decision points to enable resumability and maintain context awareness.
Hold "Ctrl" to enable pan & zoom

This diagram illustrates the sequential flow of an agent's execution, highlighting how checkpointing is used to save the agent's state at critical decision points to enable resumability and maintain context awareness.

This theoretical foundation establishes that checkpointing is not merely a feature but a fundamental architectural pattern for building resilient, stateful, and debuggable autonomous agent systems. By grounding the ephemeral nature of in-memory computation with the permanence of a relational database, we bridge the gap between transient scripts and durable, long-running AI applications.

Basic Code Example

In a web application, autonomous agents often perform long-running tasks (e.g., processing a user request, running a workflow, or generating code). If the server crashes or the user refreshes the page, the agent's internal state (memory, conversation history, tool execution results) is lost. Checkpointing solves this by periodically saving the agent's state to a durable store. In this example, we will use PostgresSaver (via LangGraph.js) to persist the state of a simple agent into a PostgreSQL database. This ensures that even if the Node.js process restarts, the agent can resume exactly where it left off.

Visualizing the Workflow

The following diagram illustrates the data flow in our SaaS application. The user interacts with the frontend, which triggers the backend agent. The backend agent updates its state and checkpoints it to Postgres before returning a response.

The diagram illustrates the sequence where a user action triggers a backend agent to update its state, checkpoint the data to Postgres, and then return a response to the frontend.
Hold "Ctrl" to enable pan & zoom

The diagram illustrates the sequence where a user action triggers a backend agent to update its state, checkpoint the data to Postgres, and then return a response to the frontend.

Implementation: Basic Checkpointing

This example demonstrates a simple agent that counts the number of steps it has taken. We will simulate a "server restart" by running the agent twice in sequence, showing that the second run resumes the count from the first run.

Prerequisites: 1. A running PostgreSQL instance (local or cloud like Supabase). 2. Environment variables set: DATABASE_URL.

// File: src/checkpoint-demo.ts

import { StateGraph, END, MemorySaver } from "@langchain/langgraph";
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
import { BaseMessage, HumanMessage } from "@langchain/core/messages";
import { z } from "zod";

/**
 * 1. DEFINE STATE SCHEMA
 * We define the shape of the state our agent will hold.
 * In a real app, this might include 'conversationHistory', 'userProfile', etc.
 */
const AgentStateSchema = z.object({
  messages: z.array(z.instanceOf(BaseMessage)),
  stepCount: z.number().default(0),
  status: z.enum(["running", "completed"]).default("running"),
});

type AgentState = z.infer<typeof AgentStateSchema>;

/**
 * 2. DEFINE AGENT NODES (LOGIC)
 * These are the individual steps in our workflow.
 */

/**
 * Node A: Process Input
 * Increments the step count and logs the input.
 */
async function processInput(state: AgentState): Promise<Partial<AgentState>> {
  console.log(`[Node ProcessInput] Current Step Count: ${state.stepCount}`);
  return {
    stepCount: state.stepCount + 1,
    messages: [...state.messages, new HumanMessage("Processing input...")],
  };
}

/**
 * Node B: Finalize
 * Sets status to completed and increments step count.
 */
async function finalize(state: AgentState): Promise<Partial<AgentState>> {
  console.log(`[Node Finalize] Current Step Count: ${state.stepCount}`);
  return {
    stepCount: state.stepCount + 1,
    status: "completed",
    messages: [...state.messages, new HumanMessage("Task completed.")],
  };
}

/**
 * 3. BUILD THE GRAPH
 * We create a workflow that goes: Start -> ProcessInput -> Finalize -> End
 */
function createWorkflow() {
  const workflow = new StateGraph(AgentStateSchema)
    // Define nodes
    .addNode("process_input", processInput)
    .addNode("finalize", finalize)
    // Define edges (workflow logic)
    .addEdge("process_input", "finalize")
    .addEdge("finalize", END)
    // Set the entry point
    .setEntryPoint("process_input");

  return workflow;
}

/**
 * 4. MAIN EXECUTION FUNCTION
 * This function simulates the SaaS backend logic.
 */
async function runCheckpointDemo() {
  // --- CONFIGURATION ---
  // In a real app, use process.env.DATABASE_URL
  const postgresUrl = "postgresql://user:password@localhost:5432/mydb";

  console.log("--- Starting Checkpoint Demo ---");

  // Initialize the Postgres Checkpointer
  // This connects to the DB and prepares the 'langgraph_checkpoint' table
  const checkpointer = new PostgresSaver({
    connectionString: postgresUrl,
  });

  // Wait for the checkpointer to be ready (connection established)
  await checkpointer.setup();

  // Create the workflow
  const app = createWorkflow();

  // Compile the graph with the checkpointer
  const compiledApp = app.compile({
    checkpointer,
    // We set a unique ID for this specific conversation/session
    // In a web app, this would be the userId or chatId
    config: { configurable: { thread_id: "user-session-123" } },
  });

  // --- SCENARIO 1: FIRST RUN (Simulating initial request) ---
  console.log("\n>>> SCENARIO 1: Initial Request");
  const initialInput = {
    messages: [new HumanMessage("Hello, agent!")],
  };

  try {
    // .stream() returns an async iterator. We use for await to process chunks.
    // In a web app, you would stream these chunks to the frontend via Server-Sent Events (SSE).
    const stream = await compiledApp.stream(initialInput);

    for await (const chunk of stream) {
      // Log the update from the specific node
      if (chunk?.process_input) {
        console.log("Stream Update:", {
          stepCount: chunk.process_input.stepCount,
          status: chunk.process_input.status,
        });
      } else if (chunk?.finalize) {
        console.log("Stream Update:", {
          stepCount: chunk.finalize.stepCount,
          status: chunk.finalize.status,
        });
      }
    }

    // Verify state was saved
    // We manually query the checkpointer to show it exists in DB
    const savedState = await checkpointer.get({
      configurable: { thread_id: "user-session-123" },
    });
    console.log(`\n[DB Check] State saved. Final Step Count: ${savedState?.stepCount}`);
  } catch (error) {
    console.error("Error in first run:", error);
  }

  // --- SCENARIO 2: SECOND RUN (Simulating a restart or new request) ---
  // We simulate a "restart" by creating a NEW instance of the compiled app
  // but using the SAME checkpointer and SAME thread_id.
  console.log("\n>>> SCENARIO 2: Server Restart / Follow-up Request");

  const app2 = createWorkflow();
  const compiledApp2 = app2.compile({
    checkpointer,
    config: { configurable: { thread_id: "user-session-123" } },
  });

  // Note: We pass an empty object as input because the graph will 
  // automatically load the last saved state for this thread_id.
  const stream2 = await compiledApp2.stream({});

  for await (const chunk of stream2) {
    if (chunk?.process_input) {
      console.log("Stream Update:", {
        stepCount: chunk.process_input.stepCount,
        status: chunk.process_input.status,
      });
    }
  }

  console.log("\n--- Demo Complete ---");
  console.log("Notice how the stepCount continued from 2 to 3, proving state was restored.");
}

// Execute the demo
runCheckpointDemo().catch(console.error);

Line-by-Line Explanation

1. Define State Schema

const AgentStateSchema = z.object({
  messages: z.array(z.instanceOf(BaseMessage)),
  stepCount: z.number().default(0),
  status: z.enum(["running", "completed"]).default("running"),
});
* Why: LangGraph requires a strict schema to manage state. We use zod for runtime validation. * How: stepCount is the integer we will persist. messages holds the conversation history. BaseMessage is a specific LangChain class that handles text content and metadata.

2. Define Agent Nodes

async function processInput(state: AgentState): Promise<Partial<AgentState>> {
  console.log(`[Node ProcessInput] Current Step Count: ${state.stepCount}`);
  return { stepCount: state.stepCount + 1, ... };
}
* Why: Nodes are the atomic units of logic. They take the current state and return updates. * Under the Hood: When the checkpointer is active, LangGraph automatically loads the previous state before calling this function. If this is the first run, state.stepCount is 0 (default). If this is a resumed run, state.stepCount will be whatever was saved in Postgres.

3. Build the Graph

const workflow = new StateGraph(AgentStateSchema)
  .addNode("process_input", processInput)
  .addEdge("process_input", "finalize")
  .setEntryPoint("process_input");
* Why: This defines the control flow. * How: We connect process_input to finalize, and finalize to END. This is a linear workflow.

4. Initialize PostgresSaver

const checkpointer = new PostgresSaver({
  connectionString: postgresUrl,
});
await checkpointer.setup();
* Why: This establishes the connection to the database. * Under the Hood: setup() checks if the langgraph_checkpoint table exists. If not, it creates it. This table stores the serialized state (as JSONB), metadata, and version info.

5. Compile with Checkpointer

const compiledApp = app.compile({
  checkpointer,
  config: { configurable: { thread_id: "user-session-123" } },
});
* Why: Compilation turns the abstract graph into an executable runtime. * Critical Concept: thread_id is the primary key for your conversation. Every time you want to resume a specific conversation, you must provide the same thread_id. If you omit it, LangGraph treats it as a new conversation.

6. Scenario 1: Initial Run

const stream = await compiledApp.stream(initialInput);
* Why: stream is preferred for web apps to provide real-time feedback to the user. * Process: 1. The graph starts at process_input. 2. It executes the logic. 3. Checkpoint: Before returning the result, LangGraph saves the state to Postgres. 4. The stream yields the update.

7. Scenario 2: Resume / Restart

const compiledApp2 = app2.compile({ checkpointer, ... });
const stream2 = await compiledApp2.stream({});
* Why: This demonstrates the "Time Travel" or resilience feature. * Under the Hood: 1. We create a new graph instance (simulating a server restart). 2. We pass an empty object {} as input. 3. LangGraph looks at the config (thread_id: "user-session-123"). 4. It queries Postgres for the latest checkpoint associated with that ID. 5. It hydrates the graph with that state. 6. It continues the execution. Notice we did not re-run finalize because it was already marked as executed in the previous checkpoint? Actually, in this linear graph, it will re-run the next available node. If the previous state was status: "completed", the graph logic might stop it. In our example, the first run completed the graph. However, if we had a loop or a conditional edge, the graph would resume from the exact node where it left off.

Common Pitfalls

  1. Missing await checkpointer.setup()

    • Issue: The PostgresSaver constructor does not automatically create the table. If you try to save a state immediately, you will get a SQL error (table does not exist).
    • Fix: Always call await checkpointer.setup() before compiling the graph or running the first execution.
  2. Mismatched thread_id

    • Issue: If you generate a random UUID for thread_id on every API request, the checkpointer will never find the previous state. You will lose memory.
    • Fix: In a SaaS app, map thread_id to a specific database ID (e.g., conversationId or userId + sessionId) and pass it consistently.
  3. Vercel/AWS Lambda Timeouts

    • Issue: Long-running agent streams might exceed the serverless function timeout (usually 10s on Vercel Hobby).
    • Fix: Do not await the full stream in the serverless function. Instead, return a 200 OK immediately and handle the stream asynchronously, or use a background job queue (like Inngest or AWS Step Functions) for long-running agents.
  4. Async/Await Loops in Streams

    • Issue: Using forEach on the stream iterator can lead to unhandled promise rejections and race conditions.
    • Fix: Always use for await (const chunk of stream) { ... } to handle asynchronous iteration safely.
  5. State Serialization Errors

    • Issue: If you try to save complex objects (like class instances) in the state that aren't serializable to JSON, Postgres will throw an error.
    • Fix: Keep state primitive. Use LangChain's BaseMessage classes (which are serializable) or plain objects/arrays. Avoid storing function references or circular structures in the state.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.