We use cookies to enhance user experience, personalize content, and analyze traffic. Cookie Policy

← Back to all articles

CrewAI vs LangGraph for Web Agents: Browsing and Blocks

CrewAI vs LangGraph for web agents, tested against 403 and 429 responses: retries, resuming failed runs, human handoff, browsing tools, and proxies.

by Unknown Proxies

13 min read

October 18, 2026

CrewAI vs LangGraph for Web Agents: Browsing and Blocks

For web agents that run unattended, LangGraph handles blocks better than CrewAI. In LangGraph, a block is control flow. Each node gets its own retry policy, a checkpointer resumes a failed run at the step that failed, and interrupt() pauses the run when a page needs a human. CrewAI gets a multi-agent browsing prototype working faster, and its checkpoints can also resume a crew or Flow after the last finished step. Its block handling, though, mostly lives inside your tools. There is no per-tool retry policy, and max_rpm paces LLM calls rather than page requests.

For browsing itself, the CrewAI vs LangGraph choice matters less than people expect. Neither framework ships a browser. Both drive the same Playwright, Browser Use, Stagehand, and MCP tools, so the pages an agent can open are about the same. The difference shows up when a page answers 403 or 429. We pointed both frameworks at a local server that returned those codes and counted how many requests each one sent.

Tested on 2026-10-18 with Python 3.12.13, langgraph 1.2.16 (langgraph-prebuilt 1.1.1), langchain 1.4.6, crewai 1.15.28, httpx 0.28.1, and requests 2.34.2. A local HTTP server returned 403, 429 (with Retry-After: 2), and 503 responses and counted every hit. A local proxy that required Basic auth logged each request. CrewAI crews ran with a scripted stand-in LLM, and LangChain agents with a scripted chat model, so the frameworks' real agent loops called the tools without a model API.

CrewAI vs LangGraph at a glance

LangGraph CrewAI
How you build the agent A graph of nodes and edges with typed state. LangChain's create_agent builds a prebuilt agent loop on top Agents with roles, grouped into crews of tasks. Flows add event-driven steps
Browsing Your own tools, LangChain integrations, or MCP servers crewai-tools browser and scrape tools, custom tools, or MCP servers
Retry on a failed fetch RetryPolicy per node, with your own retry_on No per-tool policy. Retry inside the tool
Rate limiting max_concurrency caps parallel nodes. Pacing is yours to write max_rpm per agent or crew, counted on LLM calls
Resume after a crash Checkpointer resumes at the failed node checkpoint= on a Crew or Flow, then kickoff(from_checkpoint=...) skips finished tasks or methods. Flow @persist alone reruns from @start
Human handoff on a block interrupt() pauses the run, and Command(resume=...) continues it Flow @human_feedback, or human_input=True on a task
Marking a failed tool call A raised exception, or a ToolMessage with status="error" Return a ToolFailure, then set tool_failure_policy to warn or raise
Best fit Long-running, unattended agents that must recover cleanly Fast multi-agent prototypes and role-based research crews

Both are model-agnostic Python frameworks, and LangGraph also has a JavaScript version. The rest of this guide covers what each row means when the agent works with real websites.

Browsing: both frameworks use the same tools

LangGraph has no browsing tools of its own. You write a tool, use a LangChain integration, or load MCP servers through LangChain's MCP adapters. CrewAI ships scrape and browser tools in crewai-tools (Selenium, Stagehand, Browserbase, Hyperbrowser, and others), but most serious browsing agents still end up on Playwright, Browser Use, or a Playwright MCP server. The proxy setup for those lives in the tool, and it's the same in both frameworks. The guides for Browser Use and Playwright MCP cover it.

There is one CrewAI caveat. Its built-in ScrapeWebsiteTool downloads pages through an SSRF-safe fetcher that ignores HTTPS_PROXY, so it can't use your proxy. If a CrewAI agent needs proxied fetches, write a custom tool. The one later in this guide works.

Where the browser or HTTP client lives differs more:

Parallel browsing differs too. In LangGraph, Send fans a list of URLs out to parallel nodes, and max_concurrency in the run config caps how many run at once. We sent 8 URLs to a node that took 0.5 s each: with no cap all eight ran at once and the run took about 0.5 s, while max_concurrency: 2 stretched it to 2.0 s. CrewAI has no request-level cap. Its max_rpm setting counts calls to the LLM, which we cover below.

What happens when a site blocks the agent

A block reaches your code as an HTTP status, a timeout, or an exception, depending on the client. What the framework does next decides how many more requests hit a site that has already said no. We made each setup fetch a URL that always answered 403 or 429 and counted the requests.

Bar chart of requests sent to the target after one blocked tool call: LangGraph RetryPolicy with httpx 1, LangGraph RetryPolicy with requests 3, LangChain ToolRetryMiddleware 3, CrewAI native tool calling 1, CrewAI ReAct text path 6, CrewAI returning ToolFailure 1

LangGraph's default retries depend on your HTTP client

RetryPolicy() on a node retries up to 3 attempts, with a 0.5 s initial interval, a backoff factor of 2, and jitter. Which exceptions it retries is decided by default_retry_on in LangGraph's source, and the result depends on the HTTP library your node uses:

Error in the node httpx requests
403 after raise_for_status() Not retried (1 request) Retried (3 requests)
429 after raise_for_status() Not retried (1 request) Retried (3 requests)
503 after raise_for_status() Retried (3 requests) Retried (3 requests)
Connection refused Retried (3 attempts, 3.3 s) Not retried, raised at once

The httpx column matches the intent: retry 5xx and network errors, not client errors. The requests column is inverted. The policy checks if exc.response, and a requests.Response with a 4xx or 5xx status evaluates as false, so every HTTPError is retried. requests.ConnectionError subclasses OSError, which the policy excludes, so a dead proxy is never retried. And when the policy does retry a 429, it ignores Retry-After and waits on its own backoff schedule. If your agent fetches pages, pass your own retry_on instead of relying on the default.

LangChain's create_agent stops on a tool exception

If you build the agent with LangChain's create_agent rather than a hand-written graph, tool errors behave differently. A tool that raised HTTPStatusError on a 429 ended the whole agent.invoke() call. The ToolNode that create_agent builds keeps the default error handler, which only turns argument-validation errors into messages for the model and re-raises everything else. In a hand-written graph, ToolNode(tools, handle_tool_errors=True) gives the model a ToolMessage with status="error" instead. Inside create_agent, handle errors with middleware.

ToolRetryMiddleware adds retries. By default it allows 2 retries, waits about 1 s and then 2 s (with jitter), and retries any exception. That means it also retries a 403. It sent 3 requests for both the 403 and the 429, then handed the model a message that began Tool 'fetch_page' failed after 3 attempts with HTTPStatusError. Pass a retry_on callable that returns False for 401 and 403.

CrewAI turns errors into text, and the tool path matters

By default, CrewAI catches tool exceptions and shows the error text to the model, so a raised exception doesn't end the crew. How many requests that costs depends on how the agent calls tools:

The fix is the same for both paths: don't raise from a fetch tool. CrewAI 1.15.28 has a ToolFailure type that a tool returns instead of an error string. The model still sees the message, but the failure is also recorded on TaskOutput.tool_failures, and tool_failure_policy on the tool, task, agent, or crew decides what happens next. The default, warn, records the failure and continues. raise aborts the crew with ToolExecutionFailedError. A tool returning ToolFailure for a 403 sent exactly 1 request on both paths. Under raise, the crew stopped after that one request.

Two more CrewAI settings behave differently from what their names suggest:

Resuming after a failure: checkpoints vs flow state

Blocks and timeouts end runs, and a long browsing job shouldn't start from page one when it restarts. Refetching pages you already have costs proxy bandwidth and adds load to a site that may already be rate limiting you.

Comparison of resuming after a failed fetch: LangGraph with a checkpointer resumes at the failed detail node and requests the listing page once, while a CrewAI Flow with persist restores state but restarts at start and requests the listing page twice

We ran the same two-step job in both frameworks. It fetches a listing page, then a detail page that returns 503 until we tell the test server to recover.

LangGraph with a checkpointer saved state after the listing node. The detail node failed after its retries, and the run raised. graph.get_state(config).next reported ('fetch_detail',). After the site recovered, graph.invoke(None, config) with the same thread_id ran only the detail node, so the listing page was requested once in total. InMemorySaver is fine for testing. In production, use a database-backed checkpointer (SQLite or Postgres) so state survives a restart. The durable execution docs cover the options.

CrewAI Flows with @persist saved state to SQLite. Calling kickoff(inputs={"id": flow_id}) restored that state, but the flow started again at its @start method, so the listing page was requested twice. CrewAI's newer checkpoints behave differently. With checkpoint=CheckpointConfig(on_events=["method_execution_finished"]) on the Flow, kickoff(from_checkpoint=CheckpointConfig(restore_from=path)) skipped the finished listing method and ran only the detail step, so the listing page was requested once, the same as LangGraph. The default on_events is ["task_completed"], which suits crews: Crew(checkpoint=True) writes a checkpoint after each task, and a resumed kickoff skips the tasks already done. The checkpointing docs cover storage and pruning. If you stay on @persist, have each step check state first, for example if self.state.listing: return.

One LangGraph detail costs a request if you miss it. When you resume after interrupt(), the node that called it runs again from the top. In our test, a node that fetched a page and then called interrupt() on a 403 sent the request twice: once before the pause and once on resume. The interrupts docs state this rule. Keep requests in one node and the interrupt() call in a separate node.

Handling blocks in LangGraph

The pattern that worked in both frameworks is to classify the response inside the fetch step and only give the model page text or a short failure message. Back off on 429 and 5xx, stop on 401 and 403, and never rotate IPs to get past a page that refused you.

Routing for each fetch response in a web agent: 200 returns the HTML, 429 or 5xx waits for Retry-After and retries a few times, 401 or 403 stops or pauses for a human, and timeouts or connection errors retry with backoff before testing the proxy

In LangGraph, put the fetch in its own node with a retry policy limited to the errors you want retried, and send 401 and 403 to a node that pauses the run:

import os
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from typing import TypedDict

import httpx
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from langgraph.types import Command, RetryPolicy, interrupt


class Throttled(Exception):
    """A 429 or 5xx response that is worth retrying after a pause."""


@dataclass
class Ctx:
    http: httpx.Client  # not serializable, so it goes in context, not state


class State(TypedDict, total=False):
    url: str
    status: int
    html: str
    decision: str


def retry_after_seconds(value: str | None, default: float = 5.0) -> float:
    if not value:
        return default
    if value.isdigit():
        return float(value)
    try:
        return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
    except (TypeError, ValueError):
        return default


def fetch(state: State, runtime: Runtime[Ctx]) -> dict:
    r = runtime.context.http.get(state["url"])
    if r.status_code == 429 or r.status_code >= 500:
        time.sleep(min(retry_after_seconds(r.headers.get("Retry-After")), 60))
        raise Throttled(f"HTTP {r.status_code}")
    return {"status": r.status_code, "html": r.text}


def route(state: State) -> str:
    return "ask_human" if state["status"] in (401, 403) else END


def ask_human(state: State) -> dict:
    # This node runs again from the top on resume, so it makes no requests.
    decision = interrupt({"url": state["url"], "status": state["status"]})
    return {"decision": decision}


builder = StateGraph(State, context_schema=Ctx)
builder.add_node(
    "fetch",
    fetch,
    retry_policy=RetryPolicy(
        max_attempts=4,
        initial_interval=1.0,
        retry_on=(Throttled, httpx.TransportError),
    ),
)
builder.add_node("ask_human", ask_human)
builder.add_edge(START, "fetch")
builder.add_conditional_edges("fetch", route, ["ask_human", END])
builder.add_edge("ask_human", END)
graph = builder.compile(checkpointer=InMemorySaver())

http = httpx.Client(
    proxy=os.environ["PROXY_URL"],  # http://USERNAME:PASSWORD@HOST:PORT
    trust_env=False,
    timeout=30,
    follow_redirects=True,
)
ctx = Ctx(http=http)
config = {"configurable": {"thread_id": "job-42"}}

result = graph.invoke({"url": "https://example.com/catalog"}, config, context=ctx)
if "__interrupt__" in result:
    print("Paused:", result["__interrupt__"][0].value)
    result = graph.invoke(Command(resume="skip"), config, context=ctx)
print(result.get("status"), result.get("decision"))

Through the authenticating proxy, a URL that returned one 429 with Retry-After: 2 and then 200 took 2 requests and about 4 s: the 2 s from the header plus the policy's own jittered interval. A 403 took 1 request. The run paused with {'url': ..., 'status': 403} and finished with decision='skip' without fetching again. In a real agent, the ask_human node is where an operator logs in, solves a challenge in a live browser view, or decides to drop the URL.

Two notes on this code. RetryPolicy adds its own interval on top of the Retry-After sleep, so lower initial_interval if that matters. And trust_env=False means a stray HTTPS_PROXY or NO_PROXY can't change the route. The httpx proxy docs cover the proxy= argument.

Handling blocks in CrewAI

CrewAI has no node to hang a retry policy on, so the tool does the retrying and reports failures with ToolFailure instead of raising:

import os
import time

import httpx
from crewai import Agent, Crew, Task
from crewai.tools import BaseTool, ToolFailure
from pydantic import PrivateAttr


class FetchPage(BaseTool):
    name: str = "fetch_page"
    description: str = "Fetch a URL through the proxy and return its HTML."
    _http: httpx.Client = PrivateAttr()

    def __init__(self, proxy_url: str, **kwargs):
        super().__init__(**kwargs)
        self._http = httpx.Client(proxy=proxy_url, trust_env=False, timeout=30, follow_redirects=True)

    def _run(self, url: str) -> str | ToolFailure:
        # Never raise from here: return a ToolFailure so CrewAI doesn't re-run the call.
        for attempt in range(3):
            try:
                r = self._http.get(url)
            except httpx.TransportError as e:
                last = ToolFailure(message=f"Network error for {url}: {e}", retryable=True)
                if attempt < 2:
                    time.sleep(2**attempt)
                continue
            if r.status_code == 429 or r.status_code >= 500:
                wait = r.headers.get("Retry-After", "")
                last = ToolFailure(message=f"HTTP {r.status_code} from {url}", code=str(r.status_code), retryable=True)
                if attempt < 2:  # no point waiting after the last attempt
                    time.sleep(min(float(wait), 60) if wait.isdigit() else 2**attempt)
                continue
            if r.status_code in (401, 403):
                return ToolFailure(message=f"Blocked with HTTP {r.status_code} at {url}. Do not retry it.", code=str(r.status_code))
            return r.text[:20_000]
        return last


researcher = Agent(
    role="Catalog researcher",
    goal="Read product pages and report their titles",
    backstory="You read pages with fetch_page and never guess content.",
    tools=[FetchPage(os.environ["PROXY_URL"])],  # http://USERNAME:PASSWORD@HOST:PORT
    max_iter=8,
    max_rpm=20,  # paces LLM calls, not page requests
)
task = Task(
    description="Fetch {url} and report the page title.",
    expected_output="The page title, or the HTTP status if the page was blocked.",
    agent=researcher,
)
output = Crew(agents=[researcher], tasks=[task]).kickoff(inputs={"url": "https://example.com/catalog"})
print(output.raw)
for record in output.tasks_output[0].tool_failures:
    print(record.summary())

We ran this crew on both tool paths, native function calling and ReAct text. The rate-limited-once URL took 2 requests and returned the page. The 403 took 1 request on both paths, compared with 6 on the ReAct path when the tool raised. The always-429 URL took 3 spaced requests, and the model then saw HTTP 429 from ... (code: 429). Every failure appeared in tool_failures as a line like Tool 'fetch_page' failed during 'Fetch ... and report the page title.': Blocked with HTTP 403 at .... Set tool_failure_policy="raise" on the crew if a block should stop the run rather than leave the next step to the model.

For a human handoff in CrewAI, move the work into a Flow. A @router step can send blocked URLs to a method decorated with @human_feedback, and the human feedback in Flows docs cover async feedback providers that pause a persisted flow and resume it later with from_pending() and resume(). The docs recommend making the steps that run after a resume idempotent, which for a fetch step means not requesting a page the state already holds.

Where the proxy goes in each framework

The proxy belongs on the client that fetches pages, not on the whole agent process. That's the same in both frameworks. Export HTTPS_PROXY globally and the OpenAI and Anthropic SDKs follow it, so prompts and responses also go through, billed as proxy bandwidth. In the code above, the proxy is set only on the fetch tool's httpx.Client, and model traffic goes direct.

Match the proxy to the job:

A proxy changes where requests come from. It doesn't fix a fingerprint problem, and it doesn't make a 403 acceptable to work around. Why web browsing agents get blocked separates IP blocks from fingerprint and behavior blocks. For 429s, the HTTP 429 guide covers Retry-After and request pacing. Keep agents to sites you're allowed to automate, and respect robots.txt and published rate limits.

Which one should you pick?

Pick LangGraph when the agent runs unattended, fetches many pages per job, or has to recover from blocks without a person watching. Per-node retry policies, checkpoint resume, and interrupt() are built for that. Expect to write more code: you design the graph, the state, and the fetch tool.

Pick CrewAI when you want a working multi-agent browsing prototype quickly, or when the problem fits role-based agents (researcher, analyst, writer) with short jobs that can simply rerun. Write your fetch tools to retry internally and return ToolFailure, and treat max_rpm as a model budget, not a politeness setting.

You don't have to choose only one. A LangGraph node is a Python function, so it can call crew.kickoff(). Some teams keep a CrewAI crew for the reasoning-heavy part and wrap it in a LangGraph graph that owns fetching, retries, and checkpoints.

FAQ

Is LangGraph or CrewAI better for web scraping agents?

For long-running scraping agents, LangGraph. Its retry policies are set per node, its checkpointer resumes at the failed step, and interrupt() hands blocked pages to a person. CrewAI can also resume from a checkpoint and works well for short research crews, but you write the retry and pacing logic inside each tool.

Does LangGraph retry HTTP 429 errors by default?

Only if you fetch with requests. With httpx, the default RetryPolicy doesn't retry a 429 or 403 raised by raise_for_status(). With requests, it retries every HTTPError, including 403. Neither honors Retry-After, so pass your own retry_on and sleep for the header value in the node.

Can CrewAI retry failed tool calls?

Not with a retry policy. On native function calling, a raised exception is shown to the model once. On the ReAct text path, CrewAI re-runs a raising tool immediately, which sent 6 requests for one blocked call in our test. Retry inside the tool with backoff, and return a ToolFailure when it gives up.

Does CrewAI's max_rpm limit requests to websites?

No. max_rpm counts LLM calls. When the limit is reached, CrewAI sleeps for 60 seconds before the next model call. Page requests made by tools aren't counted, so pace them inside the tool.

Can a LangGraph or CrewAI agent solve CAPTCHAs?

Neither framework solves CAPTCHAs. LangGraph's interrupt() can pause a run so a person can finish a challenge in a live browser session, and CrewAI Flows can do the same with @human_feedback. If a site serves CAPTCHAs to your agent on every request, slow down or ask the site for API access rather than adding more IPs.

Conclusion

In the CrewAI vs LangGraph comparison for web agents, browsing is close to a tie, because both drive the same browser and MCP tools. Both can resume a failed run from a checkpoint. Block handling is where they split. LangGraph gives you per-node retry policies, resume at the exact node that failed, and interrupt() for a human handoff. CrewAI gives you a faster start, and it needs fetch tools that retry internally, return ToolFailure, and don't rely on max_rpm to protect the target site.

Whichever you choose, check how many requests one blocked call really sends before the agent runs at scale. Then put the proxy on the fetch tool only: residential proxies for rotating or sticky sessions, or ISP proxies for a stable address.

About the Author

Unknown Proxies

Proxy Infrastructure Team

Stay Unknown

High-performance dedicated proxies optimized for speed and reliability. Get uncompromising quality, 99.9% uptime, and unmatched support. Stay Unknown.

Explore Plans