{"slug": "the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with", "title": "The scrollHeight Lie: How I Finally Got Full-Page Screenshots Right with Playwright", "summary": "A developer building knallhart.dev, a tool that takes full-page screenshots of websites for AI critique, found that relying on document.body.scrollHeight for scrolling is unreliable due to lazy-loaded content and infinite scroll. They developed a more robust method that scrolls step by step and monitors window.scrollY for changes, combined with waiting for image loads, to ensure accurate full-page captures.", "body_md": "If you've ever tried to take a full-page screenshot of a modern website\n\nprogrammatically, you've probably run into the same wall I did:\n\n`document.body.scrollHeight`\n\nlies. Constantly.\n\nLazy-loaded images, infinite scroll sections, sticky headers, scroll-triggered\n\nanimations — all of these mess with the page's reported height in ways that\n\nmake `scrollHeight`\n\nan unreliable signal for \"have I reached the bottom of\n\nthis page yet?\"\n\nI was building [knallhart.dev](https://knallhart.dev), a tool that takes a\n\nfull-page screenshot of any website and sends an AI-generated critique via\n\nemail. The screenshot step turned out to be the hardest part of the entire\n\nbuild — harder than the AI integration, harder than the payment flow.\n\nMy first approach was the obvious one: read `scrollHeight`\n\n, calculate how\n\nmany scroll steps are needed, scroll that many times, done.\n\n``` js\nconst pageHeight = await page.evaluate(() => document.body.scrollHeight);\nconst steps = Math.ceil(pageHeight / 600);\n\nfor (let i = 0; i < steps; i++) {\n  await page.evaluate((y) => window.scrollBy(0, y), 600);\n  await page.waitForTimeout(200);\n}\n```\n\nThis worked on simple static pages and broke immediately on anything modern.\n\nPages with lazy-loaded content report a *short* initial `scrollHeight`\n\n, then\n\ngrow as you scroll — so the calculated step count is wrong before you've\n\neven started. Pages with infinite scroll never stop growing at all.\n\nInstead of trying to calculate the destination upfront, I scroll step by\n\nstep and watch whether `window.scrollY`\n\nis still changing. If it stops\n\nchanging for a few consecutive checks, I've actually hit the bottom —\n\nregardless of what `scrollHeight`\n\nclaims.\n\n``` js\nlet lastScrollY = -1;\nlet noChangeCount = 0;\nconst maxAttempts = 40; // safety cap, ~8 seconds\n\nfor (let i = 0; i < maxAttempts; i++) {\n  await page.evaluate(() => window.scrollBy(0, 600));\n  await page.waitForTimeout(200);\n\n  const currentScrollY = await page.evaluate(() => window.scrollY);\n\n  if (currentScrollY === lastScrollY) {\n    noChangeCount++;\n    if (noChangeCount >= 3) break; // confirmed: no more progress\n  } else {\n    noChangeCount = 0;\n    lastScrollY = currentScrollY;\n  }\n}\n\nawait page.evaluate(() => window.scrollTo(0, 0));\n```\n\nThis is more robust because it doesn't trust any single height value — it\n\ntrusts *observed behavior over time*. A page that's still loading content\n\nwill keep moving `scrollY`\n\n; a page that's truly done won't, no matter how\n\nconfusing its `scrollHeight`\n\nis.\n\nTriggering the scroll isn't enough on its own — lazy-loaded images often\n\nneed a moment to actually finish loading after they enter the viewport.\n\nI added an explicit wait for image load events before taking the final\n\nscreenshot:\n\n\\\\javascript\n\nawait page.evaluate(async () => {\n\nconst images = Array.from(document.querySelectorAll(\"img\"));\n\nawait Promise.all(\n\nimages.map((img) => {\n\nif (img.complete) return Promise.resolve();\n\nreturn new Promise((resolve) => {\n\nimg.addEventListener(\"load\", resolve);\n\nimg.addEventListener(\"error\", resolve);\n\nsetTimeout(resolve, 5000); // don't hang forever on one broken image\n\n});\n\n})\n\n);\n\n});\n\n\\\\\n\nDon't trust any single DOM measurement as an endpoint signal on a page you\n\ndon't control. Modern websites are dynamic enough that almost any static\n\nvalue can lie to you at some point. Behavior over time — does this keep\n\nchanging or not — is a much sturdier signal than asking the DOM \"are we\n\nthere yet?\"\n\nIf you're curious what this screenshot pipeline turned into:\n\n[knallhart.dev](https://knallhart.dev) — it roasts your website with AI\n\nand emails you three things that are actually wrong with it.\n\nHappy to go deeper on any part of this if useful.", "url": "https://wpnews.pro/news/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with", "canonical_source": "https://dev.to/knallhartdev/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with-playwright-4emn", "published_at": "2026-06-21 16:59:14+00:00", "updated_at": "2026-06-21 17:03:55.394864+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["knallhart.dev", "Playwright"], "alternates": {"html": "https://wpnews.pro/news/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with", "markdown": "https://wpnews.pro/news/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with.md", "text": "https://wpnews.pro/news/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with.txt", "jsonld": "https://wpnews.pro/news/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with.jsonld"}}