LangGraph Tutorial (TypeScript): caching embeddings for intermediate developers

By Cyprian AaronsUpdated 2026-04-22
langgraphcaching-embeddings-for-intermediate-developerstypescript

This tutorial shows you how to add a simple embedding cache to a LangGraph workflow in TypeScript. You need this when the same text gets embedded repeatedly across runs, which wastes API calls, adds latency, and burns money for no gain.

What You'll Need

  • Node.js 18+
  • TypeScript 5+
  • @langchain/core
  • @langchain/openai
  • @langchain/langgraph
  • dotenv
  • An OpenAI API key in OPENAI_API_KEY
  • A place to persist cache data:
    • for this tutorial: a local JSON file
    • for production: Redis, Postgres, DynamoDB, or similar

Install the packages:

npm install @langchain/core @langchain/openai @langchain/langgraph dotenv
npm install -D typescript tsx @types/node

Step-by-Step

  1. Start with a small graph state that carries the input text and the generated embedding. The cache key should be deterministic, so normalize the text before hashing it.
import "dotenv/config";
import { createHash } from "crypto";

export type GraphState = {
  text: string;
  cacheKey?: string;
  embedding?: number[];
};

export function normalizeText(text: string): string {
  return text.trim().toLowerCase().replace(/\s+/g, " ");
}

export function makeCacheKey(text: string): string {
  return createHash("sha256").update(normalizeText(text)).digest("hex");
}
  1. Add a file-backed cache helper. This keeps the example executable without extra infrastructure, but the interface is the same shape you would use with Redis or a database.
import { promises as fs } from "fs";

const CACHE_FILE = "./embedding-cache.json";

export async function loadCache(): Promise<Record<string, number[]>> {
  try {
    const raw = await fs.readFile(CACHE_FILE, "utf8");
    return JSON.parse(raw) as Record<string, number[]>;
  } catch {
    return {};
  }
}

export async function saveCache(cache: Record<string, number[]>): Promise<void> {
  await fs.writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf8");
}
  1. Build the graph nodes. The first node checks cache state, and the second node only calls OpenAI when there is a miss. This is where you save real money in repeated workflows.
import { ChatOpenAI } from "@langchain/openai";
import { StateGraph, START, END } from "@langchain/langgraph";
import { GraphState, makeCacheKey } from "./state.js";
import { loadCache, saveCache } from "./cache.js";

const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });

async function checkCache(state: GraphState) {
  const cache = await loadCache();
  const cacheKey = makeCacheKey(state.text);
  return {
    cacheKey,
    embedding: cache[cacheKey],
  };
}

async function embedIfNeeded(state: GraphState) {
  if (state.embedding) return {};

  const response = await model.invoke([
    { role: "user", content: `Return a short semantic summary of this text: ${state.text}` },
  ]);

  const vector = Array.from(Buffer.from(response.content.toString()).values()).slice(0, 32);
  const normalized = vector.map((n) => n / 255);

  const cache = await loadCache();
  if (state.cacheKey) {
    cache[state.cacheKey] = normalized;
    await saveCache(cache);
  }

  return { embedding: normalized };
}
  1. Wire the graph together and compile it. LangGraph will pass state from one node to the next; your job is to keep each node focused on one responsibility.
const graph = new StateGraph<GraphState>()
  .addNode("checkCache", checkCache)
  .addNode("embedIfNeeded", embedIfNeeded)
  .addEdge(START, "checkCache")
  .addEdge("checkCache", "embedIfNeeded")
  .addEdge("embedIfNeeded", END);

export const app = graph.compile();
  1. Run the graph twice with the same input. The first run writes to cache; the second run should hit cached state and skip recomputation.
async function main() {
  const input = { text: "LangGraph caching embeddings for repeated customer support tickets." };

  const first = await app.invoke(input);
  console.log("First run:", first);

  const second = await app.invoke(input);
  console.log("Second run:", second);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
  1. If you want this to be production-ready, replace the file helper with a shared store and keep the same graph shape. The key idea is that caching belongs in its own node so it stays testable and easy to swap out.
type CacheStore = {
  get(key: string): Promise<number[] | undefined>;
  set(key: string, value: number[]): Promise<void>;
};

export async function embedWithStore(
  text: string,
  store: CacheStore,
): Promise<number[]> {
  const key = makeCacheKey(text);
  const cached = await store.get(key);
  
   if (cached) return cached;

   const response = await model.invoke([
     { role: "user", content: `Return a short semantic summary of this text: ${text}` },
   ]);

   const vector = Array.from(Buffer.from(response.content.toString()).values())
     .slice(0, 32)
     .map((n) => n / 255);

   await store.set(key, vector);
   return vector;
}

Testing It

Run the script once and confirm that embedding-cache.json gets created with a hash key and numeric vector value. Run it again with identical input and verify that the second result matches the first without changing the cached file.

Change one word in the input text and run it again. You should get a different cache key and a fresh cached entry.

If you wire in logging around checkCache, you should see a clear hit/miss pattern across runs. That is the behavior you want before moving this into Redis or Postgres.

Next Steps

  • Replace the file cache with Redis using TTLs for automatic eviction.
  • Use real embeddings from OpenAIEmbeddings instead of summarization-based vectors.
  • Add LangGraph branching so downstream nodes behave differently on cache hits vs misses.

Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides