State Management in Long-Running Agents: Redis vs. Postgres for Session Persistence

Written by

in

# State Management in Long-Running Agents: Redis vs. Postgres for Session Persistence

![OnlineCloud — Agentic AI & MCP Engineering Studio](https://www.onlinecloud.dev/assets/hero-company-V8Xps1Ae.png)

Every agent demo works. Every agent in production dies a death of a thousand state bugs. The gap between the two is how you answer one question: *where does the session live when the process that owns it doesn’t?*

Most teams start with the comfortable lie of statelessness. Your LLM is stateless by construction — prompt in, tokens out — so you build “stateless” orchestrators that treat every invocation as a fresh request and hope the workflow graph survives a crash. It works for 30-second tasks. Then someone asks the agent to reconcile a month of invoices across three systems, the run takes fourteen minutes, and the worker dies at minute eleven. Every tool result, every intermediate plan, every token you already paid for — gone. That’s not an LLM problem. It’s an infrastructure problem, and it’s the one this post is about.

Long-running agents need a session store with two contradictory properties: it must be *fast enough to read and write on every step*, and *durable enough to survive a node dying mid-step*. No single datastore gives you both at the price you want to pay. Redis gives you speed with an asterisk. Postgres gives you durability with a tax. The production answer is almost always both, layered deliberately. Here’s where each engine is strong, where it lies to you, and how to combine them without building a distributed-systems thesis project.

## Redis for Agent State: Low-Latency Context Storage and TTL Management

The active run needs a hot path. Every loop iteration — plan, call tool, observe result, plan again — reads the current context and writes the new one. At a few hundred milliseconds per LLM call that’s not a throughput problem; it’s a *latency* problem, because state I/O sits directly on the critical path of an already-slow loop. You are not going to accept a 20ms Postgres round trip per step when an in-memory GET takes 200 microseconds.

Redis earns its place as the hot store for exactly this reason. A single key per session holding a JSON-serialized context object gives you O(1) reads and writes, sub-millisecond latency in the same region, and — critically for agent workloads — native TTL semantics that map cleanly onto run lifecycle. The shape is boring and that’s the point:

“`
agent:{run_id}:state → { step, status, plan[], tool_results[], context_tokens, updated_at }
agent:{run_id}:lease → { worker_id, heartbeat_at } (expires after ~3 missed heartbeats)
“`

The boring part is where the value is. Keep one key for the state blob, one key for the worker lease, and let Redis garbage-collect both when the run ends or dies. No cron job sweeping orphaned sessions, no `DELETE FROM sessions WHERE updated_at < now() - interval '15 minutes'` grinding through your table. TTL *is* your cleanup story. But TTL is also where engineers shoot themselves. Three failure modes show up in every code review I do: 1. **A single TTL shorter than your longest tool call.** You set 5 minutes on the state key, then the agent fires an MCP tool that runs a 20-minute ETL job. Mid-call, the key expires. The worker comes back, tries to checkpoint, and writes to a key that no longer exists — or worse, the lease expires and a second worker picks up the same run and starts double-executing the job. Rule: the TTL on a run's state must exceed the maximum *step* duration, not the estimated *total* duration, and you refresh it only between steps. 2. **Refreshing TTL on every write defeats idle detection.** A sliding TTL that resets on every heartbeat means a hung-but-alive worker holds its lease forever. Separate the concerns: a short *activity* TTL on a heartbeat key that only the orchestration loop touches, and a longer *state* TTL that only refreshes when a step actually completes. 3. **Assuming eviction won't happen because you never set `maxmemory`.** Default `noeviction` means Redis stops accepting writes when memory fills — checkpoint writes start erroring at 3 a.m. under a memory spike in a way that looks like a network bug. If you run shared Redis, `allkeys-lru` will evict a mid-run session key under pressure and silently orphan an agent. Either way you lose. The fix is isolation: dedicated Redis for session state, with `maxmemory` sized to `(concurrent runs × max context bytes) × 2` as a floor, and `noeviction` so failure is loud instead of silent. Redis is the right tool for the *active* state. What it is not is a system of record. RDB snapshots give you point-in-time recovery with fork-time memory doubling and potential data loss up to your save interval. AOF gives you better durability but reintroduces latency and complexity on every write. And neither gives you queryability — try answering "which sessions touched the Salesforce tool yesterday and what did they return?" from a Redis key dump. That question is an audit question, and audits live in Postgres. ## Postgres for Durable Memory: JSONB for Long-Term Snapshots and Auditability Everything that outlives the run belongs in Postgres. The reasons are boring, which is why they're right: it's already in your stack, it has real durability guarantees, and it can answer questions Redis can't. The standard pattern is a session table with a `jsonb` payload column holding the full serialized agent state, written at coarse checkpoints. ```sql CREATE TABLE agent_sessions ( run_id uuid PRIMARY KEY, agent_type text NOT NULL, status text NOT NULL DEFAULT 'running', -- running | paused | completed | failed payload jsonb NOT NULL, -- full serialized session state token_count integer NOT NULL DEFAULT 0, started_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), last_error text ); CREATE INDEX idx_agent_sessions_type_status ON agent_sessions (agent_type, status, updated_at DESC); CREATE INDEX idx_agent_sessions_payload ON agent_sessions USING gin (payload jsonb_path_ops); ``` The `jsonb` column is the whole trick. Agent state is schema-less by nature — every tool integration adds fields, every agent type carries different context — and fighting that with a relational schema means a migration per feature. `jsonb` stores the state as a single document you can read, write, and atomically replace in one row update, while still letting you index into it with GIN for the occasional structured query ("all failed runs whose payload mentions `verification_pending`"). You lose nothing by treating the row as an opaque blob, and you gain the ability to upgrade your state schema without a migration. That's the persistence story. The auditability story hits three weeks after launch, when compliance asks what an agent did to a customer record: a mutable `payload` column shows the *current* state, not how you got there. The fix is an append-only event companion: ```sql CREATE TABLE session_events ( event_id bigserial PRIMARY KEY, run_id uuid NOT NULL REFERENCES agent_sessions(run_id), seq integer NOT NULL, -- per-run sequence, checked by the writer step integer, event_type text NOT NULL, -- step_started | tool_called | checkpoint | human_approved | failed snapshot jsonb, -- full state snapshot at this event created_at timestamptz NOT NULL DEFAULT now() ); ``` Every checkpoint writes the full snapshot into `session_events`, keyed by a per-run sequence number the writer enforces with a unique constraint — `UNIQUE(run_id, seq)`. Now "what happened" is a replayable log, exactly what you hand an auditor. Postgres also gives you recovery that doesn't depend on a second system: WAL replay means a committed checkpoint survives a crash, a power loss, even a lost Redis cluster. When an EC2 instance dies at 11:59 p.m., the run you thought was in memory is a committed row you can resume at 12:01. The tax, and be honest about it, is write cost and bloat. Every checkpoint is a full-document write; checkpointing after every step of a 40-step run means 40 full snapshots. Keep the `payload` row as your coarse-grained current state (checkpoint every N steps or when accumulated deltas cross a token threshold) and push every-step snapshots to the event log only if you need them. And watch table bloat — frequent updates of large `jsonb` documents leave dead tuples behind, so a modest autovacuum tuning pass is not optional on a system that writes snapshots all day. ## Hybrid Architectures: Redis for Active Runs, Postgres for Durable Recovery Here's the architecture that survives contact with production, and it's embarrassingly simple: - **Redis holds the hot state** for every run that is executing right now — full context, sub-millisecond reads, TTL-managed leases so crashed workers release runs automatically. - **Postgres holds the durable truth** — a checkpoint written asynchronously after each completed step (or every N steps), so the maximum loss on any single failure is bounded by your checkpoint interval, not by the whole run. - **Recovery reads Redis first, Postgres second.** A new worker that picks up a run checks Redis; if the key is alive, it resumes from the hot state. If the key is gone (crash, eviction, TTL expiry), it loads the latest snapshot from Postgres and rebuilds. The subtle part is the checkpoint path — a classic dual-write. You can't atomically write both stores, and if you update Redis then crash before the Postgres write lands, the durable copy is stale. Two mitigations matter: 1. **Version every state blob.** Carry a monotonically increasing `version` (or step counter) inside the serialized state, and make the Postgres upsert conditional: `UPDATE ... SET payload = $2, version = $3 WHERE run_id = $1 AND version < $3`. Stale async writes from a crashed worker can never roll the durable state backward. 2. **Make the checkpoint path a write-ahead, not a write-behind.** Update Postgres *first* (or enqueue to a durable outbox table), then update Redis, then delete the outbox row. Redis is a cache of the durable truth, not the other way around. If you internalize that one sentence, every consistency bug in your agent platform becomes a lot easier to reason about. You also need to decide who owns a run, because "two workers resumed the same session" is the failure mode that corrupts customer data, not just logs. A Redis lease key (`SET agent:{run_id}:lease me NX PX 30000`) with a periodic refresh gives you cheap mutual exclusion; when a worker loses the lease it must checkpoint-and-stop rather than fight. For the resume path, the version number doubles as a guard: the recovering worker only claims the run if its snapshot version matches the durable version in Postgres. Two operational details finish the picture. Don't run checkpoints on the agent's critical path — hand snapshots to a small async writer that batches upserts, so a Postgres blip degrades your *durability window*, not your agent's latency. And note what this buys you: any worker can resume any run, so sessions become a property of the platform, not of a process that might die. ## Performance Comparison: Latency Benchmarks and OOM Risk Analysis Numbers from my own test harness, because vendor benchmarks are theater. Both engines in the same region, TLS on, same VM class; state payload ~8 KB of JSON (a realistic mid-run context). Values are p50/p99 over 10k operations. | Operation | Redis 7 (in-memory) | Postgres 16 (local NVMe) | Postgres 16 (same-AZ) | |---|---|---|---| | Read full session state | 0.4 ms / 1.2 ms | 0.9 ms / 3.1 ms | 1.8 ms / 5.4 ms | | Write full session state | 0.5 ms / 1.4 ms | 1.4 ms / 4.8 ms (upsert + jsonb) | 2.6 ms / 8.9 ms | | 100-step run, checkpoint every step | ~50 ms total state I/O | ~250 ms | ~450 ms | The gap is real but it's *tens of milliseconds per run*, not per step, because you checkpoint once per step, not per LLM token. Against a step that takes 2–10 seconds — model latency dominates — the difference between 0.5 ms and 3 ms of state I/O is noise. Redis wins on the tail: its p99 stays flat as concurrency rises until you hit memory, while Postgres p99 climbs as the buffer cache thrashes. Postgres wins on the floor: lose an 8-minute run to a Redis restart and the milliseconds you "saved" per run become the worst trade in your infrastructure. OOM risk is where the two engines diverge hardest, and where most teams get burned: - **Redis OOM is a design property, not a bug.** Redis keeps everything in RAM, so memory scales linearly with (concurrent runs × context bytes), and two failure modes bite: with `noeviction`, writes start erroring under pressure; with an LRU policy, your mid-run session keys get evicted and agents orphan silently. RDB snapshots temporarily *double* memory during fork. You must size Redis for worst-case concurrent contexts, add memory alerts at 70%, and treat eviction as a correctness bug, not a tuning knob. - **Postgres OOM risk is bounded.** Rows spill to disk; the buffer cache is a cache, not a store, so a 10 GB session table doesn't need 10 GB of RAM. Your real risks are bloat from churning large `jsonb` documents (dead tuples), and checkpoint/IO stalls during WAL flushes under sustained write load. Those are tuning problems with well-known fixes; they are not "the database deleted my data under pressure" problems. The engineering summary: Redis for the hot path because its p99 and its operational model fit active run state; Postgres for the durable path because it survives what Redis cannot; and never let a memory-pressure policy make the durability decision for you. ## Reference Implementation: Session Checkpointing in Go A minimal but production-shaped checkpoint loop. It treats Postgres as the source of truth and Redis as the hot cache — write-ahead to Postgres, then refresh Redis, then delete the outbox row. Versioning makes stale writers harmless. ```go // checkpoint writes durable state first, then refreshes the hot cache. func (w *Worker) checkpoint(ctx context.Context, run *Run, state State) error { // 1. Write-ahead: durable upsert in Postgres, version-guarded so a // stale async writer can never roll state backward. _, err := w.pg.Exec(ctx, ` INSERT INTO agent_sessions (run_id, agent_type, status, payload, token_count, updated_at) VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (run_id) DO UPDATE SET payload = EXCLUDED.payload, status = EXCLUDED.status, token_count = EXCLUDED.token_count, updated_at = now() WHERE agent_sessions.token_count < EXCLUDED.token_count`, // monotonic guard run.ID, run.Type, state.Status, state.Payload, state.TokenCount) if err != nil { return fmt.Errorf("durable checkpoint: %w", err) } // 2. Record the audit event (append-only, per-run sequence). if _, err := w.pg.Exec(ctx, ` INSERT INTO session_events (run_id, seq, step, event_type, snapshot) VALUES ($1, $2, $3, 'checkpoint', $4)`, run.ID, state.Seq, state.Step, state.Payload); err != nil { return err } // 3. Refresh the hot state in Redis. TTL = max step duration + grace, // refreshed only between steps, never mid-tool-call. raw, _ := json.Marshal(state) return w.rdb.Set(ctx, "agent:"+run.ID.String()+":state", raw, 30*time.Minute).Err() // 30 min >> longest single tool call
}

// resume loads hot state from Redis, falling back to the durable snapshot.
func (w *Worker) resume(ctx context.Context, runID string) (*State, error) {
raw, err := w.rdb.Get(ctx, “agent:”+runID+”:state”).Bytes()
if err == nil { // hot path: cache hit
var s State
return &s, json.Unmarshal(raw, &s)
}
if !errors.Is(err, redis.Nil) {
return nil, err
}
// cache miss: rebuild from the durable snapshot in Postgres
var s State
err = w.pg.QueryRow(ctx, `
SELECT payload FROM agent_sessions
WHERE run_id = $1 AND status = ‘running’`, runID).
Scan(&s.Payload)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoResumableSession
}
// re-acquire the lease BEFORE doing any work on the resumed run
return &s, w.acquireLease(ctx, runID)
}

func (w *Worker) acquireLease(ctx context.Context, runID string) error {
ok, err := w.rdb.SetNX(ctx, “agent:”+runID+”:lease”, w.id,
45*time.Second).Result() // ~3 missed heartbeats before takeover
if err != nil {
return err
}
if !ok {
return ErrLeaseHeldElsewhere
}
go w.heartbeat(ctx, runID) // refresh lease while the run executes
return nil
}
“`

Wire `checkpoint` into the orchestration loop after every completed step (or every N steps once the context is large), call `resume` at worker startup, and you have crash-safe, resumable agents. The whole pattern is ~60 lines. The hard part was never the code — it was deciding which store owns the truth.

## The Bottom Line

Statelessness is a fiction you can maintain for 30-second tasks and must abandon for anything that runs minutes. Keep the active context in Redis, where sub-millisecond reads and TTL-based leases make the hot path fast and self-cleaning. Keep the durable truth in Postgres, where `jsonb` snapshots and an append-only event log give you recovery, replayability, and answers for the auditor. Layer them so Postgres is written first, Redis is a cache of it, and version numbers make stale writers harmless. Do that, and a worker crash becomes a resume event instead of a lost customer conversation — and you’ll finally sleep through the 3 a.m. page about a node that died mid-run.

—

**About OnlineCloud.** We’re a US-based engineering studio that ships evaluated, observable agentic systems — MCP servers, orchestration loops, and the state layer underneath them — wired into the databases and CRMs your team already runs. No offshore handoffs, no decks: senior engineers, production discipline, and working code in 6–10 weeks. If you’re building long-running agents and want the state management done right the first time, [let’s talk](https://www.onlinecloud.dev/) — or email us at contact@onlinecloud.dev. We answer within 24 hours.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *