n4nAI

LangChain memory for multi-user chat applications

Build production-ready multi-user chat with LangChain memory — isolation strategies, storage backends, and pitfalls that bite at scale.

n4n Team5 min read990 words

Audio narration

Coming soon — every post will get a voice note here.

LangChain memory works fine for single-user demos. In multi-user production systems, the same patterns leak context across sessions, balloon latency, and crash under concurrent load. This guide walks through the decisions that separate a prototype from a system that survives real traffic.

The core problem: memory is global by default

LangChain’s ConversationBufferMemory and its siblings store history in memory attached to a chain instance. If you instantiate one chain per request but share a single memory object — or worse, use a global singleton — every user sees every other user’s conversation. The fix is not “be careful.” The fix is architectural: memory must be scoped to a session identifier and backed by a store that survives process restarts.

# WRONG: global memory shared across all users
memory = ConversationBufferMemory(return_messages=True)
chain = ConversationChain(llm=llm, memory=memory)

# Request handler
async def chat(request):
    return chain.predict(input=request.message)  # all users share history
# RIGHT: memory scoped to session, backed by external store
async def chat(request):
    session_id = request.session_id  # from auth token, cookie, or header
    memory = await get_memory_for_session(session_id)
    chain = ConversationChain(llm=llm, memory=memory)
    return chain.predict(input=request.message)

Choose the right memory type for the job

LangChain ships several memory classes. For multi-user chat, only three matter in practice.

ConversationBufferMemory

Stores every message. Simple, predictable, grows without bound. Use when conversations are short (under 10-15 turns) or when you need full fidelity for compliance.

from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(
    return_messages=True,
    memory_key="history",
    input_key="input",
    output_key="output",
)

ConversationBufferWindowMemory

Keeps only the last k exchanges. Bounded memory footprint, drops old context. The pragmatic default for most chat applications.

from langchain.memory import ConversationBufferWindowMemory

memory = ConversationBufferWindowMemory(
    k=10,  # last 10 human/ai pairs
    return_messages=True,
)

ConversationSummaryBufferMemory

Summarizes older turns while keeping recent messages verbatim. Best for long-running conversations where you need both detail and breadth. Adds an LLM call on every write — factor that into latency and cost.

from langchain.memory import ConversationSummaryBufferMemory

memory = ConversationSummaryBufferMemory(
    llm=llm,  # separate, cheaper model for summarization
    max_token_limit=2000,
    return_messages=True,
)

Tradeoff: Summary memory introduces a failure mode — if the summarization LLM hallucinates, you lose ground truth. Buffer window is safer for high-stakes domains (medical, legal, financial).

Session isolation: where the session id comes from

The session identifier must be immutable for the conversation’s lifetime and unguessable. Three patterns work:

1. Authenticated users — derive from user ID + conversation ID

def get_session_id(user_id: str, conversation_id: str) -> str:
    return f"user:{user_id}:conv:{conversation_id}"

2. Anonymous users — cryptographically random token

import secrets

def create_anonymous_session() -> str:
    return f"anon:{secrets.token_urlsafe(32)}"

3. Stateless — embed in client-signed JWT

import jwt

def create_session_token(user_id: str, conversation_id: str, secret: str) -> str:
    payload = {"uid": user_id, "cid": conversation_id, "exp": time.time() + 86400}
    return jwt.encode(payload, secret, algorithm="HS256")

def parse_session_token(token: str, secret: str) -> tuple[str, str]:
    payload = jwt.decode(token, secret, algorithms=["HS256"])
    return payload["uid"], payload["cid"]

Never accept a session ID directly from the client without validation. An attacker who guesses or steals a session ID reads that user’s history.

Storage backends: pick one and instrument it

In-memory dictionaries ({}) work for local development. They fail the moment you run multiple replicas, restart a pod, or exceed RAM. Production needs an external store.

Redis: the default choice

Sub-millisecond latency, built-in TTL, horizontal scaling via Cluster. Use redis-py with connection pooling.

import redis.asyncio as redis
from langchain.memory import ConversationBufferWindowMemory
from langchain.schema import messages_from_dict, messages_to_dict

class RedisMemory(ConversationBufferWindowMemory):
    def __init__(self, session_id: str, redis_client: redis.Redis, ttl: int = 86400, k: int = 10):
        super().__init__(k=k, return_messages=True)
        self.session_id = session_id
        self.redis = redis_client
        self.ttl = ttl
        self.key = f"chat:memory:{session_id}"

    async def load_memory_variables(self, inputs: dict) -> dict:
        data = await self.redis.get(self.key)
        if data:
            messages = messages_from_dict(json.loads(data))
            self.chat_memory.messages = messages
        return {"history": self.chat_memory.messages}

    async def save_context(self, inputs: dict, outputs: dict) -> None:
        await super().save_context(inputs, outputs)
        data = json.dumps(messages_to_dict(self.chat_memory.messages))
        await self.redis.setex(self.key, self.ttl, data)

Pitfall: Redis memory grows with every conversation. Set a TTL (24-72 hours typical) and monitor used_memory_human. Evict stale sessions proactively.

PostgreSQL: when you need queryability

If you need to search conversations, join with user tables, or run analytics, Postgres wins. Use asyncpg or SQLAlchemy async. Store messages as JSONB.

CREATE TABLE chat_memory (
    session_id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    messages JSONB NOT NULL DEFAULT '[]',
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_chat_memory_user ON chat_memory(user_id);
CREATE INDEX idx_chat_memory_expires ON chat_memory(expires_at);
import asyncpg
import json
from datetime import datetime, timedelta

class PostgresMemory(ConversationBufferWindowMemory):
    def __init__(self, session_id: str, pool: asyncpg.Pool, k: int = 10):
        super().__init__(k=k, return_messages=True)
        self.session_id = session_id
        self.pool = pool

    async def load_memory_variables(self, inputs: dict) -> dict:
        row = await self.pool.fetchrow(
            "SELECT messages FROM chat_memory WHERE session_id = $1", self.session_id
        )
        if row:
            self.chat_memory.messages = messages_from_dict(json.loads(row["messages"]))
        return {"history": self.chat_memory.messages}

    async def save_context(self, inputs: dict, outputs: dict) -> None:
        await super().save_context(inputs, outputs)
        messages_json = json.dumps(messages_to_dict(self.chat_memory.messages))
        expires = datetime.utcnow() + timedelta(days=7)
        await self.pool.execute(
            """
            INSERT INTO chat_memory (session_id, messages, expires_at)
            VALUES ($1, $2, $3)
            ON CONFLICT (session_id) DO UPDATE SET messages = $2, updated_at = NOW(), expires_at = $3
            """,
            self.session_id, messages_json, expires
        )

Pitfall: JSONB serialization adds ~1-2ms per request. Connection pooling is mandatory — configure min_size=10, max_size=50 for typical workloads.

DynamoDB / Cosmos DB / Firestore: serverless fits

If your stack is serverless, use the native document store. Same pattern: session ID as partition key, messages as list attribute, TTL attribute for auto-expiry. Latency is higher (5-15ms) but ops burden is near zero.

Concurrency: the lost-update problem

Two requests for the same session arrive simultaneously. Both load memory, both append, both write. One write wins; the other’s turn vanishes.

Solution 1: Optimistic locking with version field

# Add version column to Postgres table
# ALTER TABLE chat_memory ADD COLUMN version INT DEFAULT 0;

async def save_context(self, inputs: dict, outputs: dict) -> None:
    await super().save_context(inputs, outputs)
    messages_json = json.dumps(messages_to_dict(self.chat_memory.messages))
    for attempt in range(3):
        result = await self.pool.execute(
            """
            UPDATE chat_memory
            SET messages = $2, version = version + 1, updated_at = NOW()
            WHERE session_id = $1 AND version = $3
            """,
            self.session_id, messages_json, self._version
        )
        if result == "UPDATE 1":
            self._version += 1
            return
        # Reload and retry
        row = await self.pool.fetchrow("SELECT messages, version FROM chat_memory WHERE session_id = $1", self.session_id)
        self.chat_memory.messages = messages_from_dict(json.loads(row["messages"]))
        self._version = row["version"]
    raise ConcurrentModificationError("Failed to save after retries")

Solution 2: Redis Lua script for atomic append

APPEND_SCRIPT = """
local key = KEYS[1]
local msg = ARGV[1]
local max_len = tonumber(ARGV[2])
local ttl = tonumber(ARGV[3])
redis.call('RPUSH', key, msg)
redis.call('LTRIM', key, -max_len, -1)
redis.call('EXPIRE', key, ttl)
return redis.call('LRANGE', key, 0, -1)
"""

async def save_context(self, inputs: dict, outputs: dict) -> None:
    # Serialize the new messages only
    new_messages = messages_to_dict(self.chat_memory.messages[-2:])  # human + ai
    for msg in new_messages:
        await self.redis.eval(APPEND_SCRIPT, 1, self.key, json.dumps(msg), self.k * 2, self.ttl)

Solution 3: Single-writer per session via distributed lock

# Redis SET NX with short TTL
lock_acquired = await self.redis.set(f"lock:{self.session_id}", "1", nx=True, ex=5)
if not lock_acquired:
    await asyncio.sleep(0.1)
    return await self.save_context(inputs, outputs)  # retry
try:
    await self._do_save()
finally:
    await self.redis.delete(f"lock:{self.session_id}")

Optimistic locking (Solution 1) scales best. Distributed locks serialize throughput — avoid unless contention is genuinely rare.

Token budgeting: prevent context window overflow

Memory feeds the prompt. If history + system prompt + user input exceeds the model’s context window, the request fails or truncates silently. Budget tokens explicitly.

import tiktoken

class TokenBudgetMemory(ConversationBufferWindowMemory):
    def __init__(self, llm, max_tokens: int = 3000, **kwargs):
        super().__init__(**kwargs)
        self.llm = llm
        self.max_tokens = max_tokens
        self.encoding = tiktoken.encoding_for_model(llm.model_name)

    def _count_tokens(self, messages: list) -> int:
        return sum(len(self.encoding.encode(m.content)) for m in messages)

    def load_memory_variables(self, inputs: dict) -> dict:
        messages = self.chat_memory.messages
        # Trim from oldest until under budget
        while messages and self._count_tokens(messages) > self.max_tokens:
            messages.pop(0)
        return {"history": messages}

Rule of thumb: Reserve 30-40% of the context window for the system prompt and expected output. For a 4k model, keep history under ~2500 tokens. For 128k models, the budget is less binding but latency grows with context — trim anyway.

Streaming and memory: don’t double-write

When streaming tokens, the final assembled message must be written to memory exactly once. A common bug: writing partial chunks on each callback, then writing the full message again at the end.

# WRONG: writes partial chunks + final message
async def on_llm_new_token(token: str):
    await memory.save_context({"input": user_input}, {"output": token})  # called per token

# RIGHT: accumulate, write once
class StreamingMemoryCallback:
    def __init__(self, memory, user_input):
        self.memory = memory
        self.user_input = user_input
        self.buffer = []

    async def on_llm_new_token(self, token: str):
        self.buffer.append(token)

    async def on_llm_end(self, response):
        full_output = "".join(self.buffer)
        await self.memory.save_context({"input": self.user_input}, {"output": full_output})

Observability: you can’t debug what you don’t measure

Instrument every memory operation. At minimum, log:

  • Session ID (hashed for privacy)
  • Memory load latency (p50, p95, p99)
  • Memory save latency
  • Message count per session
  • Token count per session
  • Error rates by type (timeout, serialization, concurrency conflict)
import time
from prometheus_client import Histogram, Counter

MEMORY_LOAD_LATENCY = Histogram("memory_load_seconds", "Memory load latency", ["backend"])
MEMORY_SAVE_LATENCY = Histogram("memory_save_seconds", "Memory save latency", ["backend"])
MEMORY_ERRORS = Counter("memory_errors_total", "Memory errors", ["backend", "error_type"])

async def load_with_metrics(self, inputs: dict) -> dict:
    start = time.perf_counter()
    try:
        result = await self._load_memory_variables(inputs)
        MEMORY_LOAD_LATENCY.labels(backend=self.backend).observe(time.perf_counter() - start)
        return result
    except Exception as e:
        MEMORY_ERRORS.labels(backend=self.backend, error_type=type(e).__name__).inc()
        raise

Alert on p95 load latency > 50ms (Redis) or > 200ms (Postgres). Alert on error rate > 0.1%.

Common pitfalls checklist

Pitfall Symptom Fix
Global memory instance Users see each other’s history Scope memory to session ID, instantiate per request
No TTL on Redis keys Memory grows until OOM Set TTL on every write; run periodic scan for orphaned keys
Unbounded buffer memory Context window exceeded, 400 errors Use ConversationBufferWindowMemory or token-budget wrapper
Double-write during streaming Duplicate assistant messages in history Accumulate stream, write once on on_llm_end
No concurrency control Lost messages under load Optimistic locking or atomic Redis ops
Session ID in URL/logs Session hijacking Use secure cookies or Authorization headers; never log raw session IDs
Synchronous Redis/Postgres in async handler Event loop blocked, latency spikes Use redis.asyncio and asyncpg; never redis-py sync or psycopg2
Single connection Connection exhaustion under load Connection pools: redis.ConnectionPool, asyncpg.create_pool

Production hardening

Graceful degradation: If the memory store is unavailable, fall back to in-memory buffer for the request duration and log a warning. Don’t fail the user’s request because Redis hiccuped.

async def get_memory_for_session(session_id: str) -> BaseMemory:
    try:
        return await RedisMemory.create(session_id, redis_pool)
    except redis.RedisError:
        logger.warning("Redis unavailable, using ephemeral memory", session_id=session_id)
        return ConversationBufferWindowMemory(k=5, return_messages=True)

Migration path: Version your memory schema. Add a schema_version field. When you change message format (e.g., adding tool calls), write a migration script and bump the version. Read logic handles both versions.

def deserialize_messages(data: dict, version: int) -> list[BaseMessage]:
    if version == 1:
        return messages_from_dict(data["messages"])
    elif version == 2:
        return messages_from_dict_v2(data["messages"])
    else:
        raise ValueError(f"Unknown schema version: {version}")

Testing: Load-test memory in isolation. Simulate 1000 concurrent sessions, 10 turns each, with 10% concurrent writes per session. Verify no lost updates, p99 latency < 100ms, memory usage stable.

Closing thought

Multi-user memory is not a LangChain problem — it’s a distributed systems problem. The framework gives you primitives; you supply the isolation, persistence, concurrency control, and observability. Treat memory like any other critical datastore: version it, monitor it, load-test it, and plan for its failure modes. The code above is a starting point, not a finish line.

Tagslangchainmemorymulti-userchat-applications

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langchain memory & conversational state posts →