How to Fix 'callback not firing when scaling' in LangGraph (Python)

By Cyprian AaronsUpdated 2026-04-21
callback-not-firing-when-scalinglanggraphpython

What this error usually means

If your callback fires for one node but stops working once you “scale” the graph, you’re usually dealing with a mismatch between LangGraph execution boundaries and your callback setup. In practice, this shows up when you move from a single-node test to a multi-node graph, add parallel branches, or start streaming/async execution.

The symptom is often one of these:

  • callback not firing when scaling
  • callbacks attached to one runnable but not the whole graph
  • handlers working in local tests but disappearing in graph.invoke() / graph.ainvoke()
  • LangChain/LangGraph events not propagating through nested nodes

The Most Common Cause — callback attached to the wrong layer

The #1 cause is attaching the callback to a single node runnable instead of the graph invocation or the actual runnable that executes at scale.

With LangGraph, a compiled graph is its own runnable. If your handler is attached only inside one node, it may work in isolation but not when the graph fans out across multiple nodes or runs in parallel.

Broken vs fixed

Broken patternFixed pattern
Callback bound only to one nodeCallback passed at graph invocation level
Works in unit test for a single functionWorks across full StateGraph execution
Misses downstream node eventsCaptures graph-wide events
# BROKEN
from langgraph.graph import StateGraph, START, END
from langchain_core.callbacks import BaseCallbackHandler

class MyHandler(BaseCallbackHandler):
    def on_chain_start(self, serialized, inputs, **kwargs):
        print("chain started")

def step_a(state):
    return {"x": 1}

def step_b(state):
    return {"y": state["x"] + 1}

builder = StateGraph(dict)
builder.add_node("a", step_a)
builder.add_node("b", step_b)
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", END)

graph = builder.compile()

# Handler attached here does NOT reliably cover the whole graph
result = graph.invoke(
    {},
    config={"callbacks": [MyHandler()]}
)
# FIXED
from langgraph.graph import StateGraph, START, END
from langchain_core.callbacks import BaseCallbackHandler

class MyHandler(BaseCallbackHandler):
    def on_chain_start(self, serialized, inputs, **kwargs):
        print("chain started")

    def on_chain_end(self, outputs, **kwargs):
        print("chain ended")

def step_a(state):
    return {"x": 1}

def step_b(state):
    return {"y": state["x"] + 1}

builder = StateGraph(dict)
builder.add_node("a", step_a)
builder.add_node("b", step_b)
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", END)

graph = builder.compile()

# Attach callbacks at the graph invocation boundary
result = graph.invoke(
    {},
    config={"callbacks": [MyHandler()]}
)

If you’re using LangChain runnables inside nodes, make sure you’re not creating a new runnable without passing config. That’s another common way callbacks get dropped.

Other Possible Causes

1) You used async nodes but called sync execution

If your nodes are async def, use ainvoke() or astream(). A sync invoke() path can lead to missing callback events or weird partial execution.

# BAD
result = await graph.invoke({"input": "hello"})  # invoke is sync

# GOOD
result = await graph.ainvoke({"input": "hello"})

If you’re streaming:

async for event in graph.astream({"input": "hello"}):
    print(event)

2) Your callback handler doesn’t implement the right methods

A lot of people override on_chain_start() and expect everything else to appear there. LangGraph may emit tool/model/node-level events that require other hooks.

from langchain_core.callbacks import BaseCallbackHandler

class MyHandler(BaseCallbackHandler):
    def on_chain_start(self, serialized, inputs, **kwargs):
        print("chain start")

    def on_chain_end(self, outputs, **kwargs):
        print("chain end")

    def on_llm_start(self, serialized, prompts, **kwargs):
        print("llm start")

    def on_tool_start(self, serialized, input_str=None, **kwargs):
        print("tool start")

If your issue is with tool execution inside a node, on_tool_start / on_tool_end matters more than chain hooks.

3) You lost config propagation inside custom node code

If a node calls another runnable and drops config, callbacks stop there.

# BAD
def node(state, config=None):
    result = llm.invoke(state["prompt"])  # config dropped
    return {"answer": result}

# GOOD
def node(state, config=None):
    result = llm.invoke(state["prompt"], config=config)
    return {"answer": result}

This matters when your graph scales into nested runnables or subgraphs.

4) Parallel branches are racing and your handler isn’t thread-safe

When you add branching with add_conditional_edges() or concurrent execution patterns, multiple callbacks can fire at once. If your handler mutates shared state without locks, it can look like callbacks “didn’t fire.”

import threading
from langchain_core.callbacks import BaseCallbackHandler

class SafeHandler(BaseCallbackHandler):
    def __init__(self):
        self.lock = threading.Lock()
        self.count = 0

    def on_chain_end(self, outputs, **kwargs):
        with self.lock:
            self.count += 1
            print(f"ended: {self.count}")

Without thread safety:

  • logs appear missing
  • counters are wrong
  • output order looks random

How to Debug It

  1. Check whether the failure happens in invoke() or only in ainvoke() / astream().

    • If sync works and async fails: your coroutine path is wrong.
    • If async works and sync fails: you may be calling async nodes incorrectly.
  2. Print every callback method.

    • Implement on_chain_start, on_chain_end, on_llm_start, and on_tool_start.
    • If only some methods fire, the issue is event type mismatch.
  3. Verify config propagation through every custom node.

    • Search for .invoke( / .ainvoke( inside nodes.
    • Make sure each call passes config=config.
  4. Reduce the graph to two nodes.

    • Start with one linear edge: START → A → END.
    • Add branches back one by one until callbacks disappear.
    • The last change usually exposes the bug: subgraph boundary, tool call wrapper, or parallel branch.

Prevention

  • Pass callbacks at the top-level graph boundary unless you have a strong reason not to.
  • Always forward config when calling nested runnables inside nodes.
  • Test both:
    • single-node execution
    • full compiled-graph execution with branches and async paths

If you want stable observability in production LangGraph apps:

  • keep callback handlers stateless where possible
  • use thread-safe counters if you must aggregate events
  • prefer explicit event hooks over assumptions about inheritance from parent runnables

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