Playwright architecture, selector reliability, and advanced interaction patterns.

Blocking Images and Fonts to Speed Up Scraping

A scraper that renders a product catalogue downloads hero photography, sprite sheets, tracking pixels, webfont subsets and video posters — none of which contribute a single character to the JSON you extract. On a typical retail page those assets are 70–85% of the transferred bytes and a large share of wall-clock load time, and you pay for them on every one of the ten thousand URLs in the queue. Playwright can drop them before a byte crosses the wire using route() handlers keyed on request.resourceType(). This page shows the exact handler to install, where it belongs in a context, which resource types are safe to kill, and the three ways blocking silently corrupts a run.

Where a route handler intercepts an asset request A page asset request reaches the context route handler, which inspects resource type and URL and either aborts the request or lets it continue to the network. Page requests an asset context.route() handler inspects resourceType + URL blocked route.abort( 'blockedbyclient') zero bytes fetched kept route.continue() document, xhr, script reach network
Every request that matches the route pattern stops at the handler, which decides between an abort and a pass-through before the browser opens a connection.

Root cause: the renderer fetches for humans, not for extractors

A headless browser is a full rendering engine, so it obeys the page exactly as a user's browser would: it decodes every <img>, resolves @font-face sources, pulls poster frames for <video>, and fires tracking beacons. None of that changes the DOM text your selectors read. The cost compounds twice — once in bytes on a metered or proxied connection, and again in CPU, because image decode and font shaping happen on the same main thread that must finish before the load event settles. Under a worker pool the CPU cost is the sharper constraint: decoding sixty concurrent hero images will saturate a scraping box long before its bandwidth runs out, which is why the technique pairs so closely with Running Parallel Scrapers with Worker Pools under Scraper Performance & Scaling.

Minimal reproducible example

Before optimising anything, measure. This test loads one representative page and attributes transferred bytes to resource type, so you know what blocking would actually buy you.

import { test } from '@playwright/test';

test('attributes transferred bytes by resource type', async ({ page }) => {
  // Accumulate response body bytes keyed by Playwright's resource type label.
  const byType = new Map<string, number>();

  page.on('requestfinished', async (request) => {
    // sizes() reads from the network stack, not from Content-Length headers,
    // so it reports what was really transferred including compression.
    const sizes = await request.sizes();
    const type = request.resourceType(); // 'document' | 'image' | 'font' | ...
    byType.set(type, (byType.get(type) ?? 0) + sizes.responseBodySize);
  });

  const started = Date.now();
  await page.goto('https://example.com/catalog', { waitUntil: 'load' });
  const elapsed = Date.now() - started;

  // Sort descending so the dominant cost is the first line you read.
  const rows = [...byType].sort((a, b) => b[1] - a[1]);
  console.log(`load ${elapsed}ms`);
  for (const [type, bytes] of rows) {
    console.log(`${type.padEnd(12)} ${(bytes / 1024).toFixed(1)} KiB`);
  }
});

Run it once with the network throttled to a realistic proxy speed. If image, media and font together account for less than a fifth of the total, blocking them is not the bottleneck and you should look at Waiting for Network Idle vs Element State instead. On most content-heavy sites they dominate by a wide margin.

Step-by-step fix

  1. Register the handler on the context, not the page. browserContext.route() applies to every page and popup the context opens, including ones created by target="_blank" links, so a scraper that follows links never leaks an unblocked page. A page.route() registered later takes precedence over the context handler for the same request, which is useful for per-page exceptions but a trap if you install both by accident. Contexts are also the isolation unit described in Browser Contexts & Isolation.

  2. Match on request.resourceType() rather than file extension alone. Extensions lie: CDN URLs routinely serve images from /i/1234?fmt=webp with no suffix at all, and a .php endpoint can return a font. The renderer already knows why it issued each request, and resourceType() exposes that classification — document, stylesheet, image, media, font, script, texttrack, xhr, fetch, eventsource, websocket, manifest, other.

  3. Abort with 'blockedbyclient', not the default. A bare route.abort() fails the request as net::ERR_FAILED, which some front ends treat as a transient network fault and retry in a loop. route.abort('blockedbyclient') surfaces net::ERR_BLOCKED_BY_CLIENT, the same code a content blocker produces — a condition production sites are already built to tolerate.

  4. Narrow the URL pattern when the asset paths are predictable. Only requests matching the pattern cross the driver boundary; everything else stays on the browser's own path with no per-request round trip. A '**/*' pattern with a predicate inside the handler is the flexible form, but on a page issuing 600 requests the interception overhead is measurable. Where URLs are regular, a glob such as '**/*.{png,jpg,jpeg,webp,avif,gif,woff,woff2,ttf,otf}' is cheaper.

  5. Block third-party beacons by hostname. Analytics, session-replay and consent scripts are script and xhr requests, so a resource-type filter misses them, yet they often add seconds. Playwright accepts a predicate URL matcher, so context.route((url) => BLOCKED_HOSTS.has(url.hostname), (route) => route.abort('blockedbyclient')) reads clearly and runs before any handler body.

  6. Set serviceWorkers: 'block' on the context. Requests issued from inside an active service worker are not routable by default, so a progressive web app will happily serve images from its cache and re-fetch them in the background while your handler sees nothing. Blocking service worker registration puts every request back under the handler.

  7. Keep stylesheet unless you have proved you can live without it. Playwright's actionability checks consult computed style: an element hidden by CSS has no bounding box, and toBeVisible() reflects that. Strip the stylesheets and previously hidden menus, modals and off-screen carousels all become "visible", which changes what your locators match and can turn a clean extraction into duplicated rows.

  8. Re-run the extraction and diff the output. Blocking is only correct if the records it produces are byte-identical to the unblocked baseline. Capture both runs to JSON and compare; any difference means an asset you dropped was load-bearing.

Deciding which resource types to abort A decision tree branching from resource type into three outcomes: always abort decorative assets, abort stylesheets only without visibility checks, and never block data-carrying requests. request.resourceType() decides the branch image, media, font decorative payload stylesheet affects visibility document, xhr, fetch carries the data abort() safe on almost every scrape abort() only when no visibility or screenshot checks continue() never block, the extraction needs it
Resource type is the only classification the renderer guarantees, which makes it the right key for the block decision.

The full handler brings those eight decisions together. It lives in a helper so every scraper entry point installs an identical policy.

import { chromium, type BrowserContext, type Route, type Request } from 'playwright';

// Types that never contribute text, links or JSON to an extraction run.
const BLOCKED_TYPES = new Set(['image', 'media', 'font']);

// Beacons arrive as 'script' or 'xhr', so they need a hostname rule of their own.
const BLOCKED_HOSTS = new Set([
  'www.googletagmanager.com',
  'connect.facebook.net',
  'static.hotjar.com',
]);

export async function installAssetBlocking(context: BrowserContext): Promise<void> {
  // Hostname rule first: a predicate matcher avoids interception for other origins.
  await context.route(
    (url) => BLOCKED_HOSTS.has(url.hostname),
    (route: Route) => route.abort('blockedbyclient'),
  );

  // Type rule second. Handlers run in reverse registration order, so this one
  // is consulted first and falls through to the hostname rule when it declines.
  await context.route('**/*', (route: Route, request: Request) => {
    if (BLOCKED_TYPES.has(request.resourceType())) {
      return route.abort('blockedbyclient');
    }
    // fallback() hands the request to the next matching handler rather than
    // ending it here, which keeps the two rules composable.
    return route.fallback();
  });
}

async function main(): Promise<void> {
  const browser = await chromium.launch();
  // Service worker fetches bypass routing unless registration is blocked.
  const context = await browser.newContext({ serviceWorkers: 'block' });
  await installAssetBlocking(context);

  const page = await context.newPage();
  await page.goto('https://example.com/catalog', { waitUntil: 'domcontentloaded' });
  const titles = await page.getByRole('heading', { level: 3 }).allInnerTexts();
  console.log(titles.length);

  await browser.close();
}

void main();

The same interception primitives are covered from the testing angle in Intercepting and Modifying Network Requests, and the fulfil-instead-of-abort variant appears in Mocking API Responses with Playwright.

Troubleshooting variants

page.goto fails with net::ERR_BLOCKED_BY_CLIENT

Your pattern matched the main document. This happens when a glob like '**/*.{png,svg}' meets a URL such as /reports/2026-summary.svg, or when a hostname rule accidentally covers the target origin. Guard the handler with an explicit escape: check request.resourceType() === 'document' and call route.continue() first, before any other rule runs. The failure is loud rather than silent, which is the one mercy here — the navigation throws immediately instead of returning an empty page.

Lazy-loaded rows never appear after blocking images

Infinite-scroll implementations frequently key their IntersectionObserver on image containers whose height collapses to zero once the image fails, so the sentinel that triggers the next page load ends up permanently inside the viewport or never inside it at all. The fix is to stop aborting and start substituting: route.fulfill() the request with a 1×1 transparent PNG so the element still gets an intrinsic box, at a cost of a few dozen bytes served locally. If the loop still stalls, drive it explicitly rather than by scroll position, as described in Scraping Infinite Scroll Pages with Playwright.

Screenshots and layout-dependent selectors drift after blocking fonts

Without the webfont the renderer falls back to a system family with different metrics, so every text node reflows: line counts change, buttons wrap, and boundingBox() returns different coordinates. Text extraction is unaffected because the DOM is unchanged, but anything measuring geometry is not, and a snapshot suite will fail wholesale — see Visual Regression Testing for why baselines are so sensitive to font metrics. Run visual checks in a separate project without the block, or keep fonts and drop only image and media on that project.

Page load timeline with and without asset blocking Two stacked timeline bars comparing an unblocked page load against one where image and font requests are aborted, showing the time reclaimed. 0s 2s 4s 6s 8s no blocking baseline images + fonts aborted time reclaimed document stylesheet script images fonts
The document, stylesheet and script segments are unchanged; the entire saving comes from the image and font tail that the handler never lets start.

Verification

Prove three things before you ship the handler. First, the saving is real: re-run the byte-attribution test from above with blocking installed and confirm the image, media and font rows are gone and the load duration dropped. A run that saves bytes but not time usually means the assets were never on the critical path and the bottleneck is elsewhere.

Second, the extraction is unchanged. Scrape the same fifty URLs with and without the handler, write both result sets to JSON, and diff them. Any field that differs identifies an asset your parser depended on — commonly an image alt attribute or a srcset URL you were harvesting, both of which survive blocking because they are markup, or a value written by a script you filtered by hostname, which does not.

Third, confirm the browser actually saw the aborts rather than silently serving from cache. Attach a listener and count them: page.on('requestfailed', r => { if (r.failure()?.errorText === 'net::ERR_BLOCKED_BY_CLIENT') blocked++; }). A count of zero on a page you know carries images means the handler never matched — usually a service worker still in play, or a page.route() registered elsewhere shadowing the context handler. The same evidence is visible per-request in a trace, which Reading Network and Console Tabs in Traces walks through, and it is worth checking against Anti-Bot Defenses & Rate Limiting because a client that requests markup and never a single pixel is itself a detectable pattern.

Frequently Asked Questions

Does blocking images make my scraper easier to detect?

It can. A browser that fetches HTML, CSS and JavaScript but never requests a single image or font produces a request profile no real user generates, and some bot-management vendors score exactly that signal. On defended targets, either leave a small sample of images unblocked so the pattern looks organic, or fulfil the requests locally with a tiny placeholder so the timing of the request sequence stays intact. On undefended targets the risk is negligible and the saving is worth taking in full.

Should I use route.abort() or route.fulfill() with an empty body?

Abort is faster and simpler, and it is the right default. Reach for fulfill() only when the page misbehaves on failure — when a script retries failed images in a loop, when a collapsed image container breaks a scroll observer, or when an onerror handler swaps in a heavier fallback asset. Fulfilling a 1×1 PNG with a 200 status costs almost nothing and makes the page believe the load succeeded.

Why do my aborts have no effect on some pages?

Three causes account for nearly all of it. An active service worker is serving and re-fetching outside the routing layer, which serviceWorkers: 'block' on the context resolves. A page.route() registered after your context handler is taking precedence for the same URLs, because page-level routes win. Or the assets are inline data: URIs and base64 sprites, which never become network requests at all and therefore cannot be intercepted.

Can I block JavaScript entirely instead?

Rarely. Turning off scripts with javaScriptEnabled: false skips the renderer's most expensive work, but any site that builds its content client-side will return an empty shell, and modern catalogues almost all do. If the target ships server-rendered HTML you do not need a browser at all — fetch it with an HTTP client. The browser earns its cost precisely when scripts must run, so blocking them defeats the reason you chose Playwright.

Back to overview