🎯 A customizable, anti-detection cloud browser powered by self-developed Chromium designed for web crawlers and AI Agents.👉Try Now
Back to Blog

Blocking Resources in Playwright: What It Actually Saves

Ava Wilson
Ava Wilson

Expert in Web Scraping Technologies

05-Aug-2026

TL;DR:

  • "Block images to save bandwidth" depends entirely on the page. Measured across three pages, blocking images, media, and fonts cut transfer by 67% on an image-heavy catalogue, about 25% on an article page, and next to nothing on a text-only listing that has no images.
  • On the text-only page the stylesheet was the payload. Adding stylesheet to the block list dropped it to 2,121 bytes — the HTML document alone — because three of its four requests are CSS.
  • Byte savings are reliable; time savings are not. Every blocked profile transferred less, but wall-clock time moved in both directions across runs, because intercepting every request has its own cost.
  • Blocking cost no data. The extracted element count was identical in all nine measurements — 20 products, 10 quotes, 36 paragraphs — so nothing useful was thrown away.
  • Measure absolute bytes, not percentages, and measure more than once. One page here has a bimodal baseline that swings its apparent saving between 1% and 65% without its content changing.
  • Start free. The Scraping Browser has a free tier, and the whole measurement in this post is nine page loads.

Playwright connects to the Scrapeless Scraping Browser over a WebSocket CDP endpoint, which means a cloud browser session behaves like a local one — and bills like a remote one. Every image, font, and stylesheet a page pulls is traffic you paid to move.

The standard advice is to block images. That advice is repeated everywhere without a number attached, so this post attaches one: the same script, three pages, three block profiles, bytes counted off the protocol.

What a Browser Session Actually Costs

A browser fetches everything a human would see. For extraction, most of that is waste — nobody parses a web font. Page weight across the web is tracked publicly by the HTTP Archive page weight report, and aggregate figures like those are what the block-images advice is built on.

Aggregates are where that advice goes wrong. You do not scrape the median page; you scrape one target, whose asset mix may look nothing like the median.

Prerequisites

  • Python 3.9 or later and pip install playwright
  • A Scrapeless API key in the SCRAPELESS_API_KEY environment variable

No local browser download is needed. connect_over_cdp attaches to a remote session, so playwright install is not part of this workflow.

Connect and Count Bytes

Playwright connects over CDP, and a raw CDP session opened on the same page counts what crosses the wire:

python Copy
browser = await playwright.chromium.connect_over_cdp(ENDPOINT)
page = await browser.new_page()
cdp = await page.context.new_cdp_session(page)
await cdp.send("Network.enable")

transferred = {"bytes": 0}
cdp.on(
    "Network.loadingFinished",
    lambda event: transferred.__setitem__(
        "bytes", transferred["bytes"] + event.get("encodedDataLength", 0)
    ),
)

encodedDataLength is the measurement that matters. The Chrome DevTools Protocol Network domain defines it as the total number of bytes received for a request, so it counts compressed transfer rather than decompressed document size. That is what a proxy meters and what a bandwidth bill reflects.

Summing it over Network.loadingFinished gives one number per page load, with no estimation anywhere in the pipeline.

Add the Routing Layer

Blocking is a routing decision made per request:

python Copy
BLOCKED = {"image", "media", "font"}


async def router(route):
    if route.request.resource_type in BLOCKED:
        await route.abort()
    else:
        await route.continue_()


await page.route("**/*", router)

Every request now passes through router, which either aborts it or lets it proceed — the handler contract in the Playwright page routing API. A handler that does neither hangs the request until the navigation times out, so every branch must end in abort or continue_.

resource_type comes from the browser's own classification of why a request was made, the same concept the Fetch Standard calls a request destination. Matching on it is more durable than matching URLs by extension, because a font served from /assets/a8f3c2 with no extension still classifies as font.

This is the mirror image of reading traffic rather than stopping it — for pulling data out of the requests a page makes, see intercepting a page's hidden JSON API.

Measure Three Pages

The full script loads each target under three profiles and prints bytes, savings, time, and the number of elements still extractable. Two contrasting fixtures are enough to make the point; add your own target to TARGETS to measure it the same way:

python Copy
import asyncio
import os
import time

from playwright.async_api import async_playwright

ENDPOINT = (
    "wss://browser.scrapeless.com/api/v2/browser"
    f"?token={os.environ['SCRAPELESS_API_KEY']}&session_ttl=300&proxy_country=US"
)

TARGETS = [
    ("books.toscrape.com", "https://books.toscrape.com/", "article.product_pod"),
    ("quotes.toscrape.com", "https://quotes.toscrape.com/", "div.quote"),
]

PROFILES = {
    "baseline": set(),
    "media-only": {"image", "media", "font"},
    "media+css": {"image", "media", "font", "stylesheet"},
}

PAUSE_SECONDS = 5


async def measure(playwright, url, selector, blocked):
    """Load one page and return (bytes over the wire, seconds, elements matched).

    Each measurement gets its own connection so no HTTP cache is shared between
    profiles; a warm cache would understate the bytes a cold load really costs.
    """
    browser = await playwright.chromium.connect_over_cdp(ENDPOINT)
    try:
        page = await browser.new_page()
        cdp = await page.context.new_cdp_session(page)
        await cdp.send("Network.enable")

        transferred = {"bytes": 0}
        cdp.on(
            "Network.loadingFinished",
            lambda event: transferred.__setitem__(
                "bytes", transferred["bytes"] + event.get("encodedDataLength", 0)
            ),
        )

        if blocked:
            async def router(route):
                if route.request.resource_type in blocked:
                    await route.abort()
                else:
                    await route.continue_()

            await page.route("**/*", router)

        started = time.monotonic()
        await page.goto(url, wait_until="load", timeout=120_000)
        elapsed = time.monotonic() - started
        matched = await page.locator(selector).count()
        return transferred["bytes"], elapsed, matched
    finally:
        await browser.close()


async def main():
    print(f"{'target':22}{'profile':12}{'bytes':>10}{'saved':>7}{'time':>8}{'matched':>9}")
    async with async_playwright() as playwright:
        for name, url, selector in TARGETS:
            baseline_bytes = None
            for profile, blocked in PROFILES.items():
                # Space the loads out: this opens a fresh session per measurement,
                # and nine back-to-back page loads is not polite to the target.
                await asyncio.sleep(PAUSE_SECONDS)
                transferred, elapsed, matched = await measure(playwright, url, selector, blocked)
                if baseline_bytes is None:
                    baseline_bytes = transferred
                saved = round((1 - transferred / baseline_bytes) * 100)
                print(f"{name:22}{profile:12}{transferred:>10,}{saved:>6}%{elapsed:>7.2f}s{matched:>9}")


if __name__ == "__main__":
    asyncio.run(main())

One run of it:

text Copy
target                profile          bytes  saved    time  matched
books.toscrape.com    baseline       337,691     0%   4.22s       20
books.toscrape.com    media-only     111,939    67%   5.62s       20
books.toscrape.com    media+css       76,329    77%   2.56s       20
quotes.toscrape.com   baseline        26,731     0%   5.86s       10
quotes.toscrape.com   media-only      26,739     0%   5.22s       10
quotes.toscrape.com   media+css        2,125    92%   3.12s       10

Keep the two quotes.toscrape.com blocked rows in mind: blocking images, media, and fonts saved nothing at all there, and blocking stylesheets on top of that saved almost everything. The next section explains why — and why the same page sometimes reports a 65% saving from that same media profile instead.

What the Numbers Say

Percentages are the natural way to read this, and they are the least stable thing in it. Blocked profiles are highly repeatable; baselines are not, and the percentage is a ratio of the two.

The catalogue page is almost perfectly repeatable — its three baseline measurements landed within 0.004% of each other. The encyclopedia article spread about 7% across runs. And the text listing turned out to have two modes: most loads transfer about 26,700 bytes, but roughly one load in three transfers about 75,820, because the browser sometimes pulls the font binaries the stylesheet references and sometimes does not. Its measured "saving" from blocking media swings between 1% and 65% on that basis alone, with the page's actual content unchanged.

So read the absolute columns first. Medians of three runs, with a real-world encyclopedia article measured the same way added as a third data point:

Page Baseline Blocking media Blocking media + CSS Elements extracted
books.toscrape.com — image catalogue 337,692 B 111,970 B 76,237 B 20 / 20 / 20
quotes.toscrape.com — text listing 27,004 B 26,725 B 2,121 B 10 / 10 / 10
en.wikipedia.org — article 473,437 B 359,075 B 358,770 B 36 / 36 / 36

The catalogue page loses two-thirds of its weight to a three-type block list, and a further ten points to stylesheets — a 67% and 77% cut against a baseline stable enough to trust those figures.

The text listing barely moves when media is blocked, then collapses to 2,121 bytes when stylesheets go too. That number is the HTML document by itself, and it was identical in every run of every profile that blocked CSS. A per-request breakdown explains why: the page makes four requests, and three of them are stylesheets totalling roughly 24,500 bytes against 2,121 bytes of markup. There are no images on it at all.

The article page gives up a quarter to media blocking and then nothing measurable to CSS — the median moved 305 bytes while the media+css runs themselves ranged over 29,000. On this page, blocking stylesheets is not a saving.

Extraction survived everywhere. 20 products, 10 quotes, and 36 paragraphs came back under every profile, so none of these savings cost a field.

The timings deserve their own warning. Every profile happened to be faster than its baseline in the run printed above, which is exactly the result that would tempt you to promise a speedup. Across the median set it does not hold: the catalogue page took 4.66s at media+css against a 3.90s baseline, and Wikipedia took 6.57s at media-only against 5.86s — both slower while transferring less. Routing every request through a Python callback adds a round trip per request, and that overhead is unrelated to how many bytes the blocked assets would have carried. Treat byte reduction as the reliable win and latency as a side effect to measure per target.

Getting started needs no card — the free plan covers a nine-load measurement like this one.

Choose a Profile Per Target

The measurement takes about a minute per target and replaces guesswork:

  • Run the baseline and one blocked profile against a representative page — a category listing, not the home page, since asset mixes differ across a site.
  • Block image, media, and font by default. These are never parsed, and the downside is bounded.
  • Test stylesheet separately rather than assuming. It was the single biggest win on one page here and irrelevant on another. Layout-dependent extraction is the thing to check: selectors keyed to classes and structure are unaffected, but anything depending on computed geometry or visibility is not.
  • Never block script or document on a page you need rendered. A client-rendered page builds its content with the scripts you would be discarding.
  • Re-measure when a target redesigns. The profile is tuned to an asset mix, and asset mixes change.

Where a page needs rendering but no interaction, an HTTP rendering call avoids the browser session entirely — the Scraping Browser product page and the connection documentation cover when a full session earns its cost.

Troubleshooting

The navigation times out as soon as routing is enabled. A code path through the handler ends without calling abort or continue_. Every branch, including the exception path, has to resolve the route.

Blocked requests still appear in the byte count. Network.enable was sent after page.route was registered, or on a different CDP session than the page being measured. Create the session from the page's own context and enable the domain before navigating.

The page renders blank and selectors match nothing. script or document is in the block list. Both are needed on a client-rendered page.

Byte counts vary by more than a few percent between runs. The page is not fetching the same assets every load. On the text listing measured here, font binaries referenced by a stylesheet were pulled on some loads and not others, moving the baseline from about 26,700 bytes to about 75,820. Take a median across several runs, and compare absolute bytes rather than percentages, since a moving baseline distorts the ratio.

Savings look large but records go missing. Compare the extracted count against the baseline before trusting a profile, as the script above does. A profile that cuts bytes and records is not an optimization.

Conclusion

Bandwidth grows with every page you collect, and unlike most costs it can be measured in about a minute. Two lines of CDP produce a number that either justifies a block list for a given target or does not.

What the numbers here argue against is a default. Blocking images was decisive on one page, moderate on another, and irrelevant on a third that has no images, while the biggest single win in the set came from blocking stylesheets on exactly that third page. Run the baseline against your own target before adopting anyone's block list, including this one — and run it more than once, because one of these three pages does not give the same answer twice.

Ready to measure your own targets? Create a free Scrapeless account, export your key, and run the script against a page you already collect. Plan limits are on the pricing page, and proxy configuration covers the egress side of the same bill.

FAQ

Q: Which resource types are safe to block when scraping?

image, media, and font are safe on almost any extraction job, because no parser reads them. stylesheet is safe whenever your selectors depend on classes and structure rather than computed layout, which covers most scraping, and it was the largest single saving in these measurements. Leave script, document, xhr, and fetch alone on any page that builds content client-side.

Q: How much bandwidth does blocking images actually save?

It depends entirely on the page, which is why a single figure is misleading. The same block list measured here saved 67% on an image catalogue, about a quarter on an encyclopedia article, and essentially nothing on a text-only listing that has no images. Run a baseline against your own target and compare absolute bytes — one minute of measurement beats any published percentage.

Q: Does blocking resources make scraping faster?

Less reliably than it reduces bytes. Every profile here transferred less than its baseline, but wall-clock times moved in both directions, because routing each request through a handler costs a round trip. On pages with few heavy assets that overhead can outweigh the savings, so treat latency as something to verify rather than assume.

Q: Should I use page.route or CDP Network.setBlockedURLs?

page.route matches on resource type, which is what you usually want, and keeps the logic in Python where it is easy to change per target. The CDP blocking commands match URL patterns, so they suit blocking a specific known host — an analytics endpoint, say — rather than a whole class of asset. The two compose if you need both.

Q: Will blocking resources make a page behave differently from a real browser?

Yes, in the sense that a session which never requests images is not doing what a browser normally does. Keep block lists to what your extraction actually needs, and if a target's behaviour changes after you enable blocking, narrow the list and re-measure rather than widening it.

Q: How do I count bytes without the CDP session?

You can approximate it by summing response body lengths in a Playwright response handler, but those are decompressed sizes rather than transferred bytes, and they miss requests that fail or are served from cache. An in-page alternative is the transferSize attribute defined by the W3C Resource Timing specification, read through performance.getEntriesByType("resource"), which is closer to the right quantity but is subject to cross-origin restrictions that zero it out for third-party assets. encodedDataLength from Network.loadingFinished is what the browser itself recorded on the wire for every request regardless of origin, so it is worth the extra two lines.

At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.

Most Popular Articles

Catalogue