Investigating OOM kills in LangGraph API under browser traffic

Hi LangChain team,

Background — what started this

Participants interacting with our AI moderator reported long pauses, then no responses ~25–30 minutes into interviews — sometimes unable to send at all.

We investigated and found the moderator-langgraph API pods repeatedly OOMKilled / restarting with exit 137: memory grows with traffic until a pod hits its limit, restarts, and climbs again. Raising the limit (2Gi→8Gi) only widened the gap between restarts — the growth continued. It scales with interview/run volume and began with our cutover to @langchain/react useStream, pointing at retained per-stream server state rather than our own app objects.

The reproduction below isolates that accumulation on a single pod.

We are investigating what looks like retained stream subscriptions / goroutines in a LangGraph API deployment under browser-driven agentic conversation traffic and client disconnects.

Setup

Frontend:

  • @langchain/react@^1.0.26
  • @langchain/langgraph-sdk@^1.9.25
  • @langchain/core@^1.1.48
  • React 18.3.1
  • React Router 7.12.0
  • Vite 5.4.11
  • Node 23.6.0
  • Bun 1.3.0

Backend:

  • Python 3.13.14
  • langgraph-api==0.10.3
  • langgraph==1.2.4
  • langgraph-sdk==0.4.2
  • langchain-core==1.4.0
  • langchain-openai==1.2.2
  • langchain-anthropic==1.4.0

Deployment/runtime:

  • Kubernetes deployment
  • LangGraph API Docker image for langgraph-api==0.10.3
  • Postgres-backed LangGraph runtime/checkpointing, with Redis used for streaming/pubsub/queueing
  • Go core API process is present and exposes pprof internally

Frontend Architecture

The browser uses the standard @langchain/react v1 hook:

const thread = useStream<ModeratorValues>({
  apiUrl,
  assistantId,
  threadId,
});

We proxy LangGraph routes through our UI server, forwarding these SDK requests:

  • GET /threads/:threadId/state
  • POST /threads/:threadId/commands
  • POST /threads/:threadId/stream/events

We are not using a custom transport, custom fetch, HttpAgentServerAdapter, polling, retries, recovery logic, or proxy-side duplicate stream cancellation.

The proxy forwards browser aborts to upstream LangGraph fetches via request.signal, so closed browser contexts / navigation aborts should propagate to upstream SSE requests.

On the frontend lifecycle side, we follow the join/rejoin guidance: disconnect without cancelling the run on page lifecycle teardown, then remount/rejoin with the same threadId where applicable.

The reproduction below mainly exercises fresh browser contexts and browser-context close, not manual refresh/rejoin of the same thread.

What Works

For the affected runs, command dispatch and graph execution look healthy:

  • /commands requests return 200
  • Runs are created once
  • Graph executions complete successfully
  • AI responses are non-empty
  • Threads return to idle

So this does not currently look like duplicate backend runs or failed generation.

Tagged Reproduction Artifacts

The files mentioned below are available in this Drive folder:

All attached files in that folder are from the adhoc_0.10.3 investigation:

  • adhoc_0.10.3_browser_2.csv
    • Generated by a small browser automation script for this reproduction.
    • This script opens fresh browser contexts, runs agentic conversations through the real React app and @langchain/react, then closes the browser context.
    • It does not simulate refresh/rejoin of the same thread.
  • go-pprof-adhoc_0.10.3_2.csv
    • Generated by repeatedly sampling the Go pprof endpoint inside the LangGraph API pod.
    • The sampler runs inside the pod against 127.0.0.1:50060/debug/pprof/goroutine?debug=2 and records memory, total goroutines, threads_server, redis_streaming, go_redis_pubsub, grpc_stream_interceptor, oldest parked select, and top goroutine states.
  • heap-adhoc_0.10.3_2.csv
    • Generated from sampled Go heap / runtime memory metrics for the same ad hoc run.
    • It tracks live heap bytes, heap in-use, Go total memory, heap stacks, goroutine count, and container working set.
  • go-pprof-adhoc_0.10.3_baseline_after_restart.csv
    • Clean baseline sample immediately after restarting the API pod showed approximately:
    • memory: 371Mi
    • total goroutines: 41
    • threads_server=0
    • redis_streaming=0
    • go_redis_pubsub=0

What Looks Wrong

With an automation script talking to our agent through the real React app and @langchain/react, the Go core API process appears to accumulate stream-related goroutines.

The browser automation was running against one LangGraph API pod. Before that pod restarted, the browser-side CSV showed:

elapsed time:             ~26.4 minutes
turns sent:               493
completed conversations:  34
errors:                   4
container memory:         426Mi -> 760Mi
container restarts:       0 -> 1 shortly after

The matching Go pprof samples for the same pre-restart process window showed:

sample window:                 22:49:46 -> 23:12:55 UTC
memory:                        491Mi -> 760Mi
total_goroutines:              124 -> 403
total_goroutines peak:         428
threads_server:                16 -> 90
redis_streaming:               16 -> 90
go_redis_pubsub:               40 -> 180
go_redis_pubsub peak:          190
grpc_stream_interceptor:       20 -> 90
grpc_stream_interceptor peak:  95
oldest_select_min:             7 -> 30

The sampled pprof data points to retained LangGraph Threads.Stream gRPC handlers and Redis PubSub relay goroutines, not our Python graph code.

At 23:13:19, the browser CSV reported restarts=1, and the next pprof sample had stream-related counts reset to zero:

23:12:55 before restart:
memory:               760Mi
total_goroutines:     403
threads_server:       90
redis_streaming:      90
go_redis_pubsub:      180

23:13:56 after restart:
memory:               333Mi
total_goroutines:     45
threads_server:       0
redis_streaming:      0
go_redis_pubsub:      0

The concern is not just a transient spike during traffic: once these stream-related goroutine and memory counts accumulate, they do not appear to return to the clean baseline after traffic stops; they reset only when the pod restarts.

We have previously noticed that when this memory growth continues, our server pods eventually restart with OOM symptoms. This run restarted before the end of the automation window, which is consistent with that failure mode.

Question

Is this expected behavior for resumable stream subscriptions in langgraph-api==0.10.3, or could this indicate a stream cleanup issue in the Go core API / Redis PubSub relay layer?

Specifically:

  1. Should client disconnects, closed browser contexts, or browser navigation aborts of /stream/events reliably tear down the corresponding Go Threads.Stream handler and Redis PubSub subscription?
  2. Is stream.disconnect() from @langchain/react expected to be sufficient client-side cleanup without cancelling the server run?
  3. Are there known issues in langgraph-api==0.10.3 around retained stream subscriptions, Redis PubSub goroutines, or resumable stream cleanup?
  4. Is there a recommended server-side timeout/TTL/configuration for abandoned /stream/events subscriptions?
  5. Would you recommend a different frontend join/rejoin pattern for React apps that proxy the LangGraph API through their own server?

Happy to provide any additional information to any questions you may have around architecture, setup, or debug information.

Any help is greatly appreciated, thanks in advance.

Hi @reecemillsom — thanks for the thorough report; the pprof and heap sampling made this easy to reason about.

Short version: this looks like it has two sides — a client/proxy side you can test today, and a server side we’ll need to investigate.

1. First, check whether your UI-server proxy forwards the disconnect. The thread event stream is torn down server-side only when that connection’s http.disconnect reaches the API. For a long-lived SSE/WS response, forwarding the request AbortSignal isn’t enough — the proxy also has to abort the upstream response when the browser goes away (and not buffer it). If that signal gets lost in the proxy hop, the run finishes but the stream subscription and its goroutines stay parked — which matches your “counts don’t return to baseline until a restart.”

2. Quickest way to test: take the proxy out of the loop. Point useStream straight at the langgraph-api deployment (inject auth via a custom transport with headers), run your reproduction, and watch the goroutines.

3. On our side, there’s a real possibility that abandoned subscriptions aren’t reaped server-side (no idle timeout on the thread stream, and the resumable-stream TTL only expires the cached data, not the subscriber). I’m flagging this to the team internally to look into.

4. Longer term — consider a supported integration pattern. You mentioned you’re not using HttpAgentServerAdapter and are hand-rolling the proxy. Our deployment cookbook has reference implementations (Next.js, SvelteKit/Cloudflare, Nuxt, Deno) built on the Agent Streaming Protocol that manage the stream lifecycle for you — worth checking if one fits, since a raw pass-through proxy puts the disconnect handling entirely on your side.

Hope that helps! Let me know what the no-proxy test shows and we’ll take it from there.

Hi @dariel.datoon thanks for the response.

I am currently looking into a couple of things around this, and I am planning to also test bypassing the proxy, to see if that does make a difference.

I will respond with the results as soon as I know.

Thanks,
Reece

Hi @dariel.datoon — thanks for the steer, that isolated it.

The no-proxy test is a clear signal: bypassing our UI proxy fixes it. With the
proxy in the SSE path, LangGraph’s stream goroutines climb without bound and never
release. Route the same browser traffic directly to the LangGraph API and they
hold the expected active-connection budget and drain to zero. So the retention
is on our side — the proxy hop — and LangGraph reaps fine once it actually receives
the disconnect.

You were right that forwarding the AbortSignal isn’t enough — and the wrinkle is
that we already go a step further (we explicitly cancel the upstream reader too),
and it still doesn’t tear down on a real browser disconnect. Evidence and the
relevant code below.

The A/B (identical both runs)

Our real React app + @langchain/react useStream 1.0.26 → LangGraph 0.10.3.
Traffic is a Playwright harness used purely as a load driver — it opens real
browser sessions and talks to the agent through the actual app to mimic
participant traffic; it’s not part of the app or the streaming path. Concurrency 5;
goroutine counters from pprof inside the langgraph-api container.

                      WITH proxy (/api/lg)     DIRECT (bypass proxy)
turns / interviews    112 / 7                  259 / 16   (more work)
threads_server        0 → 28  (climbs)         peaks ~10
go_redis_pubsub       0 → 66                   peaks ~30
after contexts close  stays high               → 0  (all counters)

Expected budget ≈ 10 threads_server for 5 pages (~2 subscriptions each: root
pump + lifecycle watcher).

What we ruled out: not duplicate runs or failed generation (/commands → 200,
runs created once, graph completes, replies non-empty, threads return to idle);
not our app memory (growth is only in the Go stream counters, our Python/JS heap is
flat); not the backend work itself (the direct route does more of it and stays
flat); not transient (proxy-path counters only reset on a pod restart).

The code

Frontend — vanilla hook pointed at our proxy, no custom transport/options:

// interview.tsx
const apiUrl = `${window.location.origin}/api/lg`;   // direct A/B test used /__langgraph
const thread = useStream<ModeratorValues>({ apiUrl, assistantId, threadId });

Proxy — a React Router (Node/Express) passthrough that forwards /api/lg/* to
the API (so the browser never holds the LangGraph URL/creds). On disconnect it
forwards the AbortSignal and explicitly cancels the upstream reader:

// api.lg.$.tsx  —  /api/lg/*  →  LangGraph
async function forward({ request, params }) {
  const upstream = await fetch(`${LANGGRAPH_URL}/${params["*"]}`, {
    method: request.method,
    headers: upstreamHeaders(request),
    signal: request.signal,                          // (a) forward browser abort → upstream fetch
  });
  return new Response(wrapSSE(upstream.body, request), {
    status: upstream.status,
    headers: sseHeaders(upstream),                   // no-buffering, drop hop-by-hop headers
  });
}

function wrapSSE(body, request) {
  const reader = body.getReader();
  const cancelUpstream = (reason) => reader.cancel(reason);

  request.signal.addEventListener("abort",           // (b) browser aborted → cancel upstream
    () => cancelUpstream(request.signal.reason), { once: true });

  return new ReadableStream({
    async pull(c) {
      const { done, value } = await reader.read();
      done ? c.close() : c.enqueue(value);
    },
    async cancel(reason) { cancelUpstream(reason); }, // downstream cancel → cancel upstream
  });
}

Why it still fails — instrumented over one leaking run (30 streams opened):

  • Per-interview browser close → 0 aborts, 0 cancels; streams accumulate.
  • Aborts only fire in a burst when the whole load process exits — and there
    reader.cancel() throws (upstream cancel FAILED).

So the upstream fetch to LangGraph is never closed; threads_server + redis
subscriptions pile up until OOM.

Direct route — for the A/B we pointed the browser straight at the API via an
istio VirtualService route (dev-only), rewriting /__langgraph/* to the
moderator-langgraph service and bypassing the UI pod entirely:

# istio VirtualService (k8s) — direct route to the LangGraph API
- match: [{ uri: { prefix: /__langgraph/threads/ } }]
  rewrite: { uri: /threads/ }
  route:  [{ destination: { host: moderator-langgraph, port: { number: 8123 } } }]
- match: [{ uri: { prefix: /__langgraph/runs/ } }]
  rewrite: { uri: /runs/ }
  route:  [{ destination: { host: moderator-langgraph, port: { number: 8123 } } }]

With that, disconnects propagate and the goroutines drain to zero. It’s diagnostic
only — we can’t ship it, since the proxy is required for auth/allowlisting.

Where we’d love your guidance

  1. A proxy is unavoidable for us. What’s the recommended pattern so browser SSE
    disconnects reliably close the upstream? React Router isn’t in the cookbook
    list — is the custom transport (HttpAgentServerAdapter) the intended path
    here?
  2. You mentioned the resumable-stream TTL expires the cached data, not the
    subscriber. In our failure the upstream is never closed, so LangGraph is never
    told the client left — is there any server-side idle/heartbeat reaping for that
    case, or is a clean upstream close the only path to teardown?

Thanks for the detailed testing — that isolates it cleanly. So the issue narrows to React Router: its Node adapter isn’t surfacing the client disconnect on a long-lived stream, so the abort your teardown depends on never fires.

Worth calling out explicitly, since it’s the puzzle in your writeup: your cancellation code is correct — you’re right to cancel the upstream reader — it just never runs. Both of your paths hang off request.signal, and that’s the signal that isn’t firing on a mid-stream browser close. The broken half is detection, not cancellation.

A couple of ways forward, depending on your constraints:

Ideally, route the stream directly. You’re already on istio, and your working A/B was essentially this. Move auth/allowlisting to the mesh (ext_authz to your auth service, or JWT RequestAuthentication + AuthorizationPolicy) and route /threads and /runs straight to the agent server. Best of both worlds: you keep the security boundary and get native disconnect propagation — which your direct test already proved drains to zero.

If you must keep the proxy, detect the disconnect at the socket layer, outside the loader — the Node response/socket close event, not the loader’s request.signal. Mount /api/lg/* as plain Node/Express handlers before the React Router request handler, tie an AbortController to res’s close, and abort the upstream fetch. That restores the signal your existing cancellation is already waiting on.

On HttpAgentServerAdapter: it’s a client-side transport for injecting auth (headers/fetch) — it doesn’t own the server-side socket teardown, so it won’t fix the proxy hop on its own. It does offer a WebSocket mode, though, whose “connection closed” event is surfaced far more reliably than a one-way stream — worth considering if you’d rather sidestep this class of bug entirely (custom transports in useStream).

On your second question — you read it right: a clean upstream close is currently the only path to teardown. There’s no idle/heartbeat reaping, and the resumable-stream TTL expires the cached data, not the subscriber, so it won’t reap an abandoned line on its own. I’ve raised it internally as a gap. If it’s blocking you and you’re on an enterprise plan, routing it through your support contact will get it prioritized faster.

Hey @dariel.datoon apologies for the slow reply.

I went with the approach for skipping our proxy layer straight to the LangGraph API for those requests that need it.

So far we are seeing that memory is much more stable, but will continue to keep an eye on it moving forwards.

Thanks for all of your help, much appreciated.

Glad to help! If you could mark the thread as solved, that would help us keep the forum clean. If you have any other questions or issues, feel free to follow-up or open a new thread.

Thanks!

might be the proxy/runtime setup not passing the disconnect through. logging the abort signal should show where it gets lost