n4nAI

CrewAI task design: async execution explained

Understand CrewAI async task execution — how parallel task running works, when to use it, and common pitfalls that break agent workflows.

n4n Team6 min read1,259 words

Audio narration

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

CrewAI async task execution lets multiple tasks run concurrently within a single crew, using Python’s asyncio to overlap I/O-bound work like LLM calls, API requests, and tool invocations. Instead of waiting for each task to finish before starting the next, the crew schedules independent tasks simultaneously and collects results as they complete. This changes the performance profile of multi-agent workflows from sequential latency accumulation to something closer to the slowest parallel branch.

How crewai async task execution works

CrewAI implements async execution through the async_execution parameter on Task objects and the process parameter on Crew. When you set async_execution=True on a task, CrewAI wraps that task’s execute() method in an asyncio.create_task() call. The crew’s event loop then runs all async-marked tasks concurrently, while tasks with async_execution=False (the default) run sequentially in the order they appear in the crew’s task list.

from crewai import Agent, Task, Crew, Process

researcher = Agent(
    role="Research analyst",
    goal="Find current data on GPU pricing",
    backstory="You track hardware markets daily",
    llm="gpt-4o-mini",
)

writer = Agent(
    role="Technical writer",
    goal="Summarize findings into a procurement brief",
    backstory="You write for engineering leads",
    llm="gpt-4o-mini",
)

# These two tasks run in parallel
research_task = Task(
    description="Research current H100 and A100 pricing from three vendors",
    expected_output="Price table with vendor, model, price, availability",
    agent=researcher,
    async_execution=True,
)

analysis_task = Task(
    description="Analyze historical price trends for the same GPUs",
    expected_output="Trend summary with 3-month trajectory",
    agent=researcher,
    async_execution=True,
)

# This task waits for both above to complete
synthesis_task = Task(
    description="Write procurement brief using research and analysis",
    expected_output="One-page brief with recommendation",
    agent=writer,
    async_execution=False,  # default, but explicit for clarity
    context=[research_task, analysis_task],
)

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, analysis_task, synthesis_task],
    process=Process.sequential,  # still sequential at crew level
    verbose=True,
)

result = crew.kickoff()

The key insight: Process.sequential at the crew level does not prevent task-level async. The crew still processes tasks in list order, but when it encounters a task with async_execution=True, it schedules that task and immediately moves to the next task in the list. If the next task also has async_execution=True, both run concurrently. The crew only blocks when it hits a task with async_execution=False that depends on prior async tasks via the context parameter.

Under the hood, CrewAI builds a dependency graph from the context references. Tasks with no unmet dependencies and async_execution=True enter the event loop together. Tasks with unmet dependencies wait. This means you control concurrency through task ordering and context wiring, not through a separate parallelism configuration.

Why async execution matters for agent workflows

LLM calls dominate latency in CrewAI workflows. A typical agent task makes 3–10 LLM calls (planning, tool use, reflection, final answer). At 2–5 seconds per call, a single task takes 10–50 seconds. Five sequential tasks means 50–250 seconds of wall-clock time. With async execution on independent tasks, that same workload finishes in roughly the duration of the slowest parallel branch plus the sequential tail.

The performance gain is real but bounded. Async helps when:

  • Tasks are I/O-bound (LLM APIs, web search, database queries)
  • Tasks are genuinely independent (no shared context dependencies)
  • You have enough API quota to sustain concurrent requests

Async hurts when:

  • Tasks share rate-limited API keys (you hit 429s faster)
  • Tasks contend for the same tools or resources
  • The overhead of task scheduling exceeds the work being done

Memory usage also increases. Each concurrent task holds its own conversation history, tool results, and intermediate state in memory. Running 10 tasks in parallel with 4k-token contexts each consumes significantly more RAM than sequential execution. For long-running crews with many tasks, this can trigger OOM kills in containerized environments.

Concrete example: parallel research and synthesis

A realistic pattern: research multiple topics in parallel, then synthesize. This mirrors how human teams work — analysts split up, then a lead compiles findings.

from crewai import Agent, Task, Crew, Process
from crewai_tools import SerperDevTool
import asyncio

search_tool = SerperDevTool()

analyst = Agent(
    role="Market analyst",
    goal="Extract actionable insights from web sources",
    backstory="You find signal in noise",
    tools=[search_tool],
    llm="gpt-4o-mini",
    max_iter=3,
)

editor = Agent(
    role="Editor",
    goal="Produce a coherent brief from multiple research streams",
    backstory="You synthesize without hallucinating",
    llm="gpt-4o-mini",
)

# Define research topics
topics = [
    "AI inference hardware market 2024",
    "Vector database adoption trends",
    "LLM routing gateway patterns",
    "GPU cloud pricing comparison",
]

research_tasks = []
for i, topic in enumerate(topics):
    task = Task(
        description=f"Research: {topic}. Find 3 credible sources with dates, "
                    f"key metrics, and vendor names. Return structured bullets.",
        expected_output="Structured bullets: source, date, key finding, relevance",
        agent=analyst,
        async_execution=True,
        # Each task gets a unique output key for later reference
        output_json={
            "topic": topic,
            "findings": "list of {source, date, metric, vendor}"
        },
    )
    research_tasks.append(task)

synthesis_task = Task(
    description="Synthesize all four research streams into a 500-word executive brief. "
                "Highlight converging trends, conflicting data, and actionable recommendations. "
                "Cite sources inline using the topic names.",
    expected_output="Executive brief with inline citations",
    agent=editor,
    async_execution=False,
    context=research_tasks,  # depends on all research tasks
)

crew = Crew(
    agents=[analyst, editor],
    tasks=research_tasks + [synthesis_task],
    process=Process.sequential,
    verbose=True,
    max_rpm=60,  # rate limit for the whole crew
)

# Run with explicit event loop handling for notebooks/scripts
if __name__ == "__main__":
    result = asyncio.run(crew.kickoff_async())
    print(result.raw)

Several things to notice:

  • max_rpm on the crew applies globally, not per-task. Four parallel research tasks sharing a 60 RPM limit means ~15 RPM each. Plan accordingly.
  • kickoff_async() returns an awaitable. In a script, you need asyncio.run(). In FastAPI or other async frameworks, you await crew.kickoff_async() directly.
  • Each research task uses output_json to structure its result. This makes the synthesis task’s context parsing reliable — the editor receives parsed JSON, not raw text.
  • The analyst agent has max_iter=3. In async mode, each task’s iterations run sequentially within that task, but different tasks iterate in parallel.

Common misconceptions

Misconception: async_execution makes the whole crew parallel

Process.sequential with async tasks creates a hybrid: parallel branches feeding sequential join points. Process.hierarchical adds a manager agent that delegates, but the manager’s delegation decisions are still sequential. True full-graph parallelism requires designing your task DAG explicitly through context dependencies.

# This does NOT run all tasks in parallel
crew = Crew(
    tasks=[task1, task2, task3],  # all async_execution=True
    process=Process.sequential,
)
# task1 starts, then task2 starts immediately, then task3 starts immediately
# All three run concurrently. But if task4 depends on all three:
task4 = Task(context=[task1, task2, task3], async_execution=False)
# task4 waits for ALL three. The crew blocks at task4.

Misconception: async tasks share agent instances safely

Agents are not thread-safe. When two async tasks use the same agent instance, they share the agent’s llm client, tools, and internal state (like iteration_count). CrewAI creates a new TaskExecution context per task, but the agent object itself is reused. If your tools have mutable state (database connections, caches, rate-limit counters), concurrent tasks will collide.

Fix: either give each task its own agent instance, or make your tools stateless and thread-safe.

# Safe: separate agent instances
analyst_1 = Agent(role="Analyst 1", ...)
analyst_2 = Agent(role="Analyst 2", ...)

task1 = Task(agent=analyst_1, async_execution=True, ...)
task2 = Task(agent=analyst_2, async_execution=True, ...)

# Risky: shared agent with stateful tool
class StatefulTool:
    def __init__(self):
        self.call_count = 0  # shared mutable state!
    
    def _run(self, query):
        self.call_count += 1
        return search(query)

shared_tool = StatefulTool()
analyst = Agent(tools=[shared_tool], ...)
# Two async tasks using 'analyst' will race on call_count

Misconception: context passes data automatically between async tasks

context=[task1, task2] tells CrewAI that the current task depends on those tasks’ outputs. It does not create a shared memory space. Each task receives the full output of its context tasks as part of its prompt. If task1 produces 50k tokens, task3 (which depends on task1) receives all 50k tokens in its context window. This compounds quickly in fan-in patterns.

Mitigation: use output_json or output_pydantic to constrain context size, or add an intermediate summarization task.

# Instead of passing raw research to synthesis
summarize_task = Task(
    description="Condense research findings to 500 words, preserving key metrics and sources",
    expected_output="Condensed summary",
    agent=summarizer,
    async_execution=False,
    context=[research_task],
)

synthesis_task = Task(
    context=[summarize_task],  # smaller context
    ...
)

Misconception: async execution works with all LLM providers

Some provider SDKs are synchronous only. CrewAI’s async execution wraps the synchronous llm.call() in asyncio.to_thread(), which works but adds thread overhead. Providers with native async clients (OpenAI, Anthropic, Google) perform better. If you’re using a local model via Ollama or a custom wrapper without async support, you’re paying thread-pool overhead for no concurrency benefit at the HTTP layer.

Check your LLM wrapper:

# Good: native async
from openai import AsyncOpenAI
client = AsyncOpenAI()

# Works but suboptimal: sync client in thread pool
from openai import OpenAI
client = OpenAI()  # CrewAI runs this in to_thread()

# Broken: custom sync-only wrapper without thread safety
class MyLLM:
    def call(self, messages): ...  # no async version

When to use sync vs async

Default to async_execution=False (sync). It’s simpler to debug, uses less memory, and avoids rate-limit collisions. Enable async selectively on tasks that meet all three criteria:

  1. I/O-bound: The task spends most time waiting on external APIs (LLM, search, database).
  2. Independent: No other task in the crew depends on its output, or its dependents are also async and can fan in later.
  3. Idempotent or retryable: If a rate limit hits, the task can retry without side effects.

Typical candidates for async:

  • Parallel research tasks (as shown above)
  • Multiple file processing tasks with independent inputs
  • Batch classification or extraction tasks
  • Tool-heavy tasks calling external APIs

Keep sync:

  • Tasks with complex tool chains that share state
  • Tasks writing to the same database or file
  • The final synthesis/aggregation task (fan-in point)
  • Any task where you need deterministic ordering for debugging

Debugging async crews

Async crews fail differently than sync crews. Common failure modes:

Silent task failures: An async task raises an exception, but the crew continues because other tasks are still running. The exception surfaces only when you await the result or when a dependent task tries to access missing context. Wrap task logic in try/except and emit structured error outputs.

from crewai import Task
from pydantic import BaseModel

class ResearchOutput(BaseModel):
    findings: list[str]
    error: str | None = None

research_task = Task(
    ...,
    output_pydantic=ResearchOutput,
    async_execution=True,
)

# In the agent's tool or custom logic:
def safe_research(topic):
    try:
        return do_research(topic)
    except Exception as e:
        return ResearchOutput(findings=[], error=str(e))

Rate limit cascades: Four async tasks hit the same provider simultaneously. All four get 429. All four retry. All four hit 429 again. The crew appears hung. Set max_rpm on the crew and implement exponential backoff in your LLM wrapper. Consider a semaphore if you need finer control.

import asyncio
from crewai import LLM

class RateLimitedLLM(LLM):
    def __init__(self, *args, max_concurrent=3, **kwargs):
        super().__init__(*args, **kwargs)
        self._semaphore = asyncio.Semaphore(max_concurrent)
    
    async def acall(self, messages, **kwargs):
        async with self._semaphore:
            return await super().acall(messages, **kwargs)

Context window explosions: A synthesis task receives context from 5 async research tasks, each with 8k tokens. The synthesis prompt exceeds the model’s context window. Use output_json with strict schemas, add summarization steps, or increase the model’s context window (e.g., gpt-4o-128k).

Non-deterministic ordering: Async tasks complete in whatever order the API responds. If your synthesis task expects context in a specific order, don’t rely on task list order. Access context by task reference or output key.

# In synthesis task description, reference explicitly:
"Use the findings from 'gpu_pricing_research' and 'historical_trends_analysis'..."
# Not: "Use the first and second research results..."

Summary

CrewAI async task execution is a targeted concurrency primitive, not a magic “go faster” button. It overlaps I/O-bound LLM calls on independent tasks, reducing wall-clock time for fan-out/fan-in workflows. The trade-offs are increased memory, rate-limit pressure, and debugging complexity. Use it on parallel research, batch processing, and independent tool calls. Keep synthesis, stateful operations, and rate-sensitive paths synchronous. Design your task DAG explicitly through context dependencies, constrain output sizes with structured schemas, and test failure modes under load before shipping to production.

Tagscrewaitask-designasynctutorial

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 crewai agent roles & task design posts →