LangChain Expression Language (LCEL) makes parallel execution straightforward once you understand the core primitives. The RunnableParallel class and RunnablePassthrough let you fan out independent sub-chains, execute them concurrently, and fan results back in — all without leaving the declarative LCEL API. This guide walks through the patterns you’ll actually use in production, from simple map-reduce flows to conditional branching with fallback logic.
Step 1: Set up the environment and verify LCEL version
Start with a clean environment. LCEL parallel primitives stabilized in langchain-core>=0.1.0, so pin accordingly.
python -m venv .venv && source .venv/bin/activate
pip install "langchain-core>=0.1.0" "langchain-openai>=0.1.0" "langchain-anthropic>=0.1.0"
Verify the imports resolve:
from langchain_core.runnables import RunnableParallel, RunnablePassthrough, RunnableLambda
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
print("Imports OK")
Run the script. If you see Imports OK, you’re on a compatible version. If you hit ImportError on RunnableParallel, upgrade langchain-core.
Step 2: Build the simplest parallel fan-out with RunnableParallel
RunnableParallel takes a dict mapping output keys to runnables. Each value runs independently; the results are merged into a single dict. This is the workhorse for langchain lcel parallel execution.
from langchain_core.runnables import RunnableParallel
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
summarize_prompt = ChatPromptTemplate.from_template("Summarize in one sentence: {text}")
keywords_prompt = ChatPromptTemplate.from_template("Extract 5 keywords as a comma-separated list: {text}")
sentiment_prompt = ChatPromptTemplate.from_template("Return only 'positive', 'negative', or 'neutral': {text}")
parallel_chain = RunnableParallel(
summary=summarize_prompt | llm,
keywords=keywords_prompt | llm,
sentiment=sentiment_prompt | llm,
)
input_text = "LangChain Expression Language lets you compose chains declaratively. Parallel execution is a first-class primitive."
result = parallel_chain.invoke({"text": input_text})
print(result)
Verify: You should see a single dict with keys summary, keywords, sentiment, each containing the respective model output. The three LLM calls executed concurrently — total latency ≈ max(individual latencies), not sum.
Step 3: Preserve and forward original inputs with RunnablePassthrough
Downstream steps often need the original input alongside parallel results. RunnablePassthrough.assign() adds computed fields to the input dict without mutating it.
from langchain_core.runnables import RunnablePassthrough
enriched = RunnablePassthrough.assign(
summary=summarize_prompt | llm,
keywords=keywords_prompt | llm,
sentiment=sentiment_prompt | llm,
)
result = enriched.invoke({"text": input_text, "source": "blog-draft"})
print(result.keys()) # dict_keys(['text', 'source', 'summary', 'keywords', 'sentiment'])
Verify: The output contains the original text and source fields plus the three new fields. This pattern avoids manual dict merging and keeps the chain composable.
Step 4: Run heterogeneous models in parallel
Different tasks suit different models. Run a cheap model for classification and a stronger model for generation simultaneously.
from langchain_anthropic import ChatAnthropic
cheap_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
strong_llm = ChatAnthropic(model="claude-3-5-sonnet-20240620", temperature=0)
classify_prompt = ChatPromptTemplate.from_template("Classify as 'technical', 'business', or 'general': {text}")
draft_prompt = ChatPromptTemplate.from_template("Write a 3-paragraph blog intro for: {text}")
hetero_chain = RunnableParallel(
category=classify_prompt | cheap_llm,
draft=draft_prompt | strong_llm,
)
result = hetero_chain.invoke({"text": "How to optimize LCEL parallel execution in production"})
print(result)
Verify: category returns fast from GPT-4o-mini; draft returns from Claude Sonnet. Both started at the same time. This is where langchain lcel parallel execution shines — you don’t pay the strong model’s latency for the classification step.
Step 5: Add async invocation for true I/O concurrency
invoke() runs the parallel branches concurrently but blocks the event loop. In an async context (FastAPI, Streamlit, background workers), use ainvoke() to yield control during network waits.
import asyncio
async def main():
result = await hetero_chain.ainvoke({"text": "Async parallel execution in LCEL"})
print(result)
asyncio.run(main())
Verify: The script completes in roughly the latency of the slowest branch. If you wrap this in a FastAPI endpoint, the server handles other requests while waiting for model responses.
Step 6: Implement conditional parallel branches with RunnableLambda
Not every input needs every branch. Use RunnableLambda to route dynamically while preserving parallelism where applicable.
from langchain_core.runnables import RunnableLambda
def route_by_length(inputs: dict) -> dict:
text = inputs["text"]
if len(text) < 100:
return {"short": True, "text": text}
return {"short": False, "text": text}
short_chain = RunnableParallel(
summary=summarize_prompt | llm,
keywords=keywords_prompt | llm,
)
long_chain = RunnableParallel(
summary=summarize_prompt | llm,
keywords=keywords_prompt | llm,
sentiment=sentiment_prompt | llm,
outline=ChatPromptTemplate.from_template("Create a 3-bullet outline: {text}") | llm,
)
conditional = RunnableLambda(route_by_length) | RunnableLambda(
lambda x: short_chain if x["short"] else long_chain
)
print(conditional.invoke({"text": "Short text."}))
print(conditional.invoke({"text": "Long text. " * 50}))
Verify: Short inputs skip the sentiment and outline branches entirely. The routing logic runs first, then the selected parallel sub-chain executes. This avoids wasted compute on trivial inputs.
Step 7: Handle partial failures with fallback runnables
Production chains need resilience. Wrap individual branches in .with_fallbacks() so one provider’s outage doesn’t kill the whole parallel group.
from langchain_core.runnables import RunnableConfig
primary = ChatOpenAI(model="gpt-4o-mini", temperature=0)
fallback = ChatAnthropic(model="claude-3-haiku-20240307", temperature=0)
robust_summary = (summarize_prompt | primary).with_fallbacks([summarize_prompt | fallback])
robust_keywords = (keywords_prompt | primary).with_fallbacks([keywords_prompt | fallback])
fallback_chain = RunnableParallel(
summary=robust_summary,
keywords=robust_keywords,
)
# Simulate by passing an invalid API key to primary (in real code, use env vars)
result = fallback_chain.invoke({"text": "Test fallback behavior"})
print(result)
Verify: If the primary model errors (rate limit, timeout, auth), the fallback model executes transparently. The parallel structure remains intact — other branches continue unaffected. This is the same pattern n4n.ai uses at the gateway level: automatic fallback when a provider is degraded, surfaced as a single OpenAI-compatible response.
Step 8: Stream partial results for perceived latency reduction
Streaming lets consumers render results as they arrive. RunnableParallel streams each branch independently; the merged stream emits dict chunks as branches complete.
async def stream_results():
async for chunk in fallback_chain.astream({"text": "Streaming parallel results in LCEL"}):
print(chunk) # Each chunk is a partial dict, e.g. {'summary': AIMessageChunk(...)}
# Render incrementally in your UI
asyncio.run(stream_results())
Verify: You’ll see chunks for summary and keywords interleaved as each model streams tokens. A frontend can render the summary while keywords are still generating.
Step 9: Compose parallel groups into larger DAGs
LCEL chains are just runnables. Nest RunnableParallel inside sequential pipes, or feed parallel output into another parallel group.
from langchain_core.output_parsers import StrOutputParser
# Stage 1: Parallel enrichment
enrich = RunnableParallel(
summary=summarize_prompt | llm | StrOutputParser(),
keywords=keywords_prompt | llm | StrOutputParser(),
)
# Stage 2: Parallel generation conditioned on enrichment
generate = RunnableParallel(
blog_post=(
ChatPromptTemplate.from_template("Write a blog post about: {summary}\nKeywords: {keywords}")
| llm
| StrOutputParser()
),
tweet=(
ChatPromptTemplate.from_template("Write a tweet summarizing: {summary}")
| llm
| StrOutputParser()
),
linkedin=(
ChatPromptTemplate.from_template("Write a LinkedIn post about: {summary}")
| llm
| StrOutputParser()
),
)
full_pipeline = enrich | generate
result = full_pipeline.invoke({"text": "LangChain LCEL parallel execution patterns"})
print(result.keys()) # blog_post, tweet, linkedin
Verify: The pipeline runs in two waves. Wave 1 (enrichment) fans out to summary + keywords. Wave 2 (generation) fans out to three social formats, each receiving the full enrichment dict. Total latency ≈ latency(wave1) + latency(slowest wave2 branch).
Step 10: Profile and verify concurrency with timing instrumentation
Don’t guess — measure. Wrap the chain to log per-branch latency.
import time
from langchain_core.runnables import RunnableLambda
def timed(name: str, runnable):
def _invoke(inputs, config=None):
start = time.perf_counter()
out = runnable.invoke(inputs, config)
elapsed = time.perf_counter() - start
print(f"[{name}] {elapsed:.2f}s")
return out
return RunnableLambda(_invoke)
profiled = RunnableParallel(
summary=timed("summary", summarize_prompt | llm),
keywords=timed("keywords", keywords_prompt | llm),
sentiment=timed("sentiment", sentiment_prompt | llm),
)
profiled.invoke({"text": "Profiling parallel execution in LCEL"})
Verify: Output shows three timestamps. The max should match the total chain latency within a few milliseconds, confirming true concurrency. If you see sequential times summing to total, check for accidental blocking calls (sync invoke inside async context, or a non-thread-safe client).
Step 11: Deploy behind a gateway for unified routing and observability
When you move to production, you’ll want a single endpoint that handles model routing, fallback, usage metering, and cache-control hints across all parallel branches. Point your LCEL chains at a gateway instead of provider SDKs directly.
# Conceptual — replace base_url in ChatOpenAI
gateway_llm = ChatOpenAI(
model="gpt-4o-mini", # logical name, gateway resolves to provider
base_url="https://api.n4n.ai/v1",
api_key="your-gateway-key",
)
# All parallel branches now inherit gateway routing, fallback, and metering
prod_chain = RunnableParallel(
summary=summarize_prompt | gateway_llm,
keywords=keywords_prompt | gateway_llm,
)
The gateway honors client routing directives (e.g., model: "anthropic/claude-3.5-sonnet") and forwards provider cache-control hints so repeated parallel calls can hit cached responses where applicable.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Total latency = sum of branches | Using invoke() in async handler without await |
Use ainvoke() or run sync code in thread pool |
RunnableParallel output missing keys |
Branch raised exception, no fallback | Add .with_fallbacks() per branch |
| Memory grows with streaming | Accumulating chunks in a list | Process chunks incrementally; don’t buffer |
| Rate limits on parallel fan-out | Too many concurrent requests to same provider | Add semaphore or use gateway with built-in queueing |
Verification checklist
-
RunnableParallelreturns merged dict with all expected keys -
RunnablePassthrough.assign()preserves original input fields - Heterogeneous models execute concurrently (wall time ≈ slowest branch)
-
ainvoke()yields control; FastAPI handles concurrent requests - Conditional routing skips unnecessary branches
- Fallback triggers on primary failure without breaking parallel structure
- Streaming emits partial dicts as branches produce tokens
- Nested parallel groups compose without deadlock
- Timing logs confirm concurrency, not sequential execution
- Gateway endpoint receives unified traffic with per-token metering
LangChain LCEL parallel execution is declarative, composable, and production-ready when you combine RunnableParallel, RunnablePassthrough, async invocation, and per-branch fallbacks. Start with the minimal fan-out in Step 2, then layer on routing, streaming, and observability as your latency and reliability requirements grow.