PixelRAG + Scrapeless: What Breaks When Screenshot Tiling Goes Remote
Lead Scraping Automation Engineer
TL;DR:
- Visual RAG indexes pages as screenshot tiles instead of parsed text, so tile geometry is not cosmetic — it decides what the embedding model actually sees.
- PixelRAG's renderer sets a capture viewport of 875 by 8192 with
Emulation.setDeviceMetricsOverride, then clips every screenshot to 875 wide. - Against a cloud browser that override reports success and never applies the requested width. Across sessions the resulting layout width has come back as anything from 945 to 5120 — sometimes unchanged, usually wider, never 875.
- The clip is honoured exactly, which is what makes this dangerous: you get a correctly sized tile containing the left slice of a page laid out much wider.
- On a real capture, an 875-wide tile of a 1920-wide layout held one product column where the page lays out four, with the card sliced through its button.
- The
rawFilePathfast path is accepted without error against a remote browser, returns inline data instead, and writes no local file — so a pipeline reading that file gets nothing while every call looks successful. - Compare the width you asked for against
innerWidthafter the override before you trust a single tile. - Start on the Scrapeless free plan and measure your own capture geometry.
The Scrapeless Scraping Browser is a cloud browser you drive over a CDP WebSocket, and every measurement below was taken against it with the same CDP calls a local renderer would use.
Visual retrieval-augmented generation skips HTML parsing. Instead of converting a page to text and embedding the chunks, it renders the page to images, embeds the images, and lets a vision model read the answer off the pixels. Tables, charts, and multi-column layouts survive, because nothing was ever flattened into a text stream.
That only works if the picture is right. A tiling renderer makes a specific promise — the page is laid out at a fixed width, then sliced into fixed-height tiles — and a visual index is only as good as that promise. PixelRAG is the clearest current example, and its renderer is worth reading closely because the assumption it makes is one every screenshot pipeline makes. This post measures what happens to that assumption on a browser you did not launch.
How Tiling Is Supposed to Work
PixelRAG's fast renderer speaks raw CDP over a WebSocket rather than going through a driver library. It sets two constants at the top of render/src/pixelrag_render/backends/fast_cdp.py:
python
VIEWPORT_WIDTH = 875
TILE_HEIGHT = 8192
Then, once per worker, it applies those as the capture viewport:
python
await conn.cdp("Emulation.setDeviceMetricsOverride", {
"width": VIEWPORT_WIDTH,
"height": tile_height,
"deviceScaleFactor": 1,
"mobile": False,
})
And for each tile it captures a clipped rectangle at the same width, stepping down the page:
python
await conn.cdp("Page.captureScreenshot", {
"clip": {"x": 0, "y": t * tile_height, "width": VIEWPORT_WIDTH,
"height": clip_h, "scale": 1},
})
The logic is sound and standard. Emulation.setDeviceMetricsOverride is the CDP call that the CSS viewport specification concepts map onto, and it is what driver libraries call underneath when you set a viewport. Lay the page out at 875, cut it into 8192-tall strips, embed the strips. On a browser you launched yourself, that is exactly what happens.
Prerequisites
- Python 3.10 or later and the
websocketspackage. - A Scrapeless API key from the dashboard, exported as
SCRAPELESS_KEY. - No local browser. Every call below goes to the cloud browser over a WebSocket connection.
bash
pip install "websockets==15.0.1"
export SCRAPELESS_KEY="your_api_key_here"
Measure the Override Instead of Trusting It
The override returns a result object with no error, which is the entire problem — nothing about the response suggests the request was not honoured. Read the layout width back from the page instead.
python
import asyncio, json, os
import websockets
CDP = ("wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_KEY']}&sessionTTL=120&proxyCountry=ANY")
WIDTH, HEIGHT = 875, 8192
async def call(ws, state, method, params=None, session=None):
state["id"] += 1
msg = {"id": state["id"], "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
await ws.send(json.dumps(msg))
while True:
reply = json.loads(await ws.recv())
if reply.get("id") == state["id"]:
return reply
async def one_session(run):
async with websockets.connect(CDP, max_size=50 * 1024 * 1024) as ws:
state = {"id": 0}
target = await call(ws, state, "Target.createTarget", {"url": "about:blank"})
tid = target["result"]["targetId"]
attached = await call(ws, state, "Target.attachToTarget",
{"targetId": tid, "flatten": True})
sid = attached["result"]["sessionId"]
await call(ws, state, "Page.enable", session=sid)
await call(ws, state, "Page.navigate",
{"url": "https://books.toscrape.com/"}, session=sid)
await asyncio.sleep(2.5)
async def inner_width():
r = await call(ws, state, "Runtime.evaluate",
{"expression": "innerWidth", "returnByValue": True},
session=sid)
return r["result"]["result"]["value"]
before = await inner_width()
result = await call(ws, state, "Emulation.setDeviceMetricsOverride",
{"width": WIDTH, "height": HEIGHT,
"deviceScaleFactor": 1, "mobile": False}, session=sid)
await asyncio.sleep(1)
after = await inner_width()
print(f"run{run}: error={result.get('error')} before={before} "
f"after={after} requested={WIDTH} applied={after == WIDTH}")
await call(ws, state, "Target.closeTarget", {"targetId": tid})
return after
async def main():
widths = [await one_session(run) for run in range(1, 4)]
print(f"distinct widths: {sorted(set(widths))} · requested {WIDTH} ever applied: "
f"{WIDTH in widths}")
asyncio.run(main())
text
run1: error=None before=945 after=1280 requested=875 applied=False
run2: error=None before=945 after=1600 requested=875 applied=False
run3: error=None before=945 after=945 requested=875 applied=False
distinct widths: [945, 1280, 1600] · requested 875 ever applied: False
Run it again and the after column holds different numbers. That instability is the result rather than noise around it, so the line worth pinning is the last one: the requested width is never the applied width.
Three things are worth separating here. The call does not fail. The width it lands on differs from session to session — across separate runs the value has come back as 945, 1240, 1280, 1400, 1440, 1600, 1680, 1920, 2560 and 5120. And in none of those sessions was it the 875 that was asked for.
Note run 3 above: sometimes the width does not move at all, and 945 before becomes 945 after. So the override is not reliably a no-op and not reliably a change either. The one property that has held in every session is the useful one — the number you request is not the number you get.
The practical reading is that the remote browser owns its own window, and the override relayouts against that window rather than against your numbers. Locally you own the process, so the same call behaves.
What the Tile Actually Contains
Now capture a tile the way a tiling renderer does, with an explicit clip at the width you thought you set.
python
import asyncio, base64, json, os, pathlib, struct
import websockets
CDP = ("wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_KEY']}&sessionTTL=120&proxyCountry=ANY")
WIDTH = 875
async def call(ws, state, method, params=None, session=None):
state["id"] += 1
msg = {"id": state["id"], "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
await ws.send(json.dumps(msg))
while True:
reply = json.loads(await ws.recv())
if reply.get("id") == state["id"]:
return reply
async def main():
async with websockets.connect(CDP, max_size=100 * 1024 * 1024) as ws:
state = {"id": 0}
tid = (await call(ws, state, "Target.createTarget",
{"url": "about:blank"}))["result"]["targetId"]
sid = (await call(ws, state, "Target.attachToTarget",
{"targetId": tid, "flatten": True}))["result"]["sessionId"]
await call(ws, state, "Page.enable", session=sid)
await call(ws, state, "Page.navigate",
{"url": "https://books.toscrape.com/"}, session=sid)
await asyncio.sleep(2.5)
await call(ws, state, "Emulation.setDeviceMetricsOverride",
{"width": WIDTH, "height": 8192,
"deviceScaleFactor": 1, "mobile": False}, session=sid)
await asyncio.sleep(1)
layout = await call(ws, state, "Runtime.evaluate",
{"expression": "innerWidth", "returnByValue": True},
session=sid)
shot = await call(ws, state, "Page.captureScreenshot",
{"format": "png", "captureBeyondViewport": True,
"clip": {"x": 0, "y": 0, "width": WIDTH,
"height": 1200, "scale": 1}}, session=sid)
image = base64.b64decode(shot["result"]["data"])
png_w, png_h = struct.unpack(">II", image[16:24])
print(f"layout width: {layout['result']['result']['value']}")
print(f"tile size: {png_w}x{png_h} (requested {WIDTH}x1200)")
pathlib.Path("tile.png").write_bytes(image)
await call(ws, state, "Target.closeTarget", {"targetId": tid})
asyncio.run(main())
text
layout width: 1920
tile size: 875x1200 (requested 875x1200)
The width and height printed here are read straight out of the image header rather than assumed, since the PNG specification puts both in the first chunk of the file. The tile is exactly the size requested. The page behind it is laid out considerably wider, and the width it lands on changes per session, so the fraction of the page you capture is not fixed either.
The saved tile.png from this run shows what that costs. At a layout width of 1920 the tile holds one product column where the page lays out four, the visible card is cut through its "Add to basket" button, and the header text runs off the right edge. Just over half the page width is outside the image.
A text scraper would not care, because it reads the DOM and the DOM is complete. A visual index cares completely: the tile is the input, and roughly half the page never reaches the embedding model. Retrieval degrades without a single error anywhere in the pipeline, which is why comparing the requested width against innerWidth is worth doing once per environment.
Want to check your own capture geometry against a cloud browser? Create a free Scrapeless account and run the two blocks above.
The Raw-Capture Path Disappears
PixelRAG's throughput trick is to skip PNG encoding in the browser. It asks Chrome to dump raw pixels straight to shared memory with a rawFilePath parameter, then compresses them in a separate process pool. The repository ships a Chromium patch file for exactly this, and the compression worker reads the file back with open(raw_path, "rb").
Against a remote browser, that parameter is accepted and quietly ignored.
python
import asyncio, json, os, pathlib
import websockets
CDP = ("wss://browser.scrapeless.com/api/v2/browser"
f"?token={os.environ['SCRAPELESS_KEY']}&sessionTTL=120&proxyCountry=ANY")
RAW = pathlib.Path("/dev/shm/tile_probe.raw")
async def call(ws, state, method, params=None, session=None):
state["id"] += 1
msg = {"id": state["id"], "method": method}
if params:
msg["params"] = params
if session:
msg["sessionId"] = session
await ws.send(json.dumps(msg))
while True:
reply = json.loads(await ws.recv())
if reply.get("id") == state["id"]:
return reply
async def main():
RAW.unlink(missing_ok=True)
async with websockets.connect(CDP, max_size=100 * 1024 * 1024) as ws:
state = {"id": 0}
tid = (await call(ws, state, "Target.createTarget",
{"url": "about:blank"}))["result"]["targetId"]
sid = (await call(ws, state, "Target.attachToTarget",
{"targetId": tid, "flatten": True}))["result"]["sessionId"]
await call(ws, state, "Page.enable", session=sid)
await call(ws, state, "Page.navigate",
{"url": "https://books.toscrape.com/"}, session=sid)
await asyncio.sleep(2.5)
shot = await call(ws, state, "Page.captureScreenshot",
{"fromSurface": True, "optimizeForSpeed": True,
"rawFilePath": str(RAW),
"clip": {"x": 0, "y": 0, "width": 875,
"height": 1000, "scale": 1}}, session=sid)
print("error:", shot.get("error"))
print("inline data returned:", len(shot["result"].get("data", "")))
print("local file exists:", RAW.exists())
await call(ws, state, "Target.closeTarget", {"targetId": tid})
asyncio.run(main())
text
error: None
inline data returned: 245576
local file exists: False
No error, real image data on the response, and nothing on disk. The path is the giveaway: /dev/shm refers to the machine running the browser, and that is not the machine running your code. A worker that opens the expected file raises FileNotFoundError for every tile, and if those failures are counted rather than raised, the run reports tiles written while the output directory stays empty.
This is the sharper of the two findings, because it is not a quality regression. It is a pipeline that produces nothing and says it worked.
What to Do Instead
Neither finding argues against remote rendering. They argue against carrying local assumptions into it.
Read geometry back rather than setting it. innerWidth after the override is the number your tiling maths should use, not the number you passed. If your tiles must be a fixed width for a model that was trained at that width, scale or pad the captured image after the fact rather than relying on the browser to lay out at your number.
Take the encoded image over the raw path. Losing rawFilePath costs PNG or JPEG encoding time and returns the bytes to your process, which is the only place they are useful when the browser is remote.
Treat tile geometry as something to assert. One check comparing requested width to measured width, run once when the environment changes, catches both of these before an index is built on them. Neither shows up as an error, and both change what the model sees.
Conclusion
Screenshot tiling assumes the renderer honours the viewport you set, and on a browser you launched that assumption is safe. On a cloud browser the override succeeds and the layout width lands somewhere else — measured here as six different values across six sessions, none of them the requested 875 — while the screenshot clip is honoured to the pixel. The result is a correctly sized tile of the wrong region.
The raw-capture path fails more cleanly and more severely: accepted, ignored, no file, no error. Check the tile count and the measured width before trusting a visual index, and read the geometry back instead of assuming it.
Deeper background on the protocol is in What Is the Chrome DevTools Protocol, the connection itself is covered in the Playwright and Scraping Browser guide, plan details are on the Scrapeless pricing page, and session parameters are in the Scrapeless documentation.
FAQ
Q: Does this mean PixelRAG cannot use a cloud browser?
Not at all — it means its renderer assumes a local one today. The tiling logic, the embedding model, and the index are unaffected; what needs changing is the capture step, which should read the layout width back and use the encoded-image response rather than the raw file path.
Q: Why does the override change the width at all if it does not apply my value?
Because it does trigger a relayout, just against the browser's own window rather than your requested metrics. The width before the call was 945 in every session measured and something different afterwards, so the call has an effect — it is the destination that is not yours to choose.
Q: Is the clip parameter reliable?
Yes, and that is what makes the mismatch easy to miss. Every capture came back at exactly the requested pixel dimensions. Correct output size is not evidence that the page underneath was laid out at that size.
Q: Would a driver library like Playwright avoid this?
No, because the driver issues the same CDP command underneath. A previous measurement on this endpoint found the Playwright viewport setter leaving the reported dimensions completely unchanged, so the symptom differs slightly by client while the cause is the same. The WebDriver BiDi specification is the standards-track effort to give remote browser control a defined contract, which is where behaviour like this would eventually be pinned down.
Q: How do I check my own setup quickly?
Set the viewport, then evaluate innerWidth and compare it to what you asked for. One line, one round trip, and it tells you immediately whether your tiling maths is operating on a real number or a hopeful one.
Q: Does a fixed-width crop actually hurt retrieval?
It changes the input distribution. An embedding model tuned on screenshots of pages laid out at a given width receives crops of wider layouts instead, with content missing on the right. The tile still embeds and still returns neighbours, so nothing surfaces as broken — the results simply get worse on pages whose useful content sits outside the crop.
Q: What should sessionTTL be for a tiling run?
Long enough to cover the whole page, since tiling is sequential and a tall page needs many captures on one session. Size it against your slowest expected page rather than an average one, and close the target when the run finishes.
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.


