Home › Testing › TimeoutException in Headless: Viewport and UA Fixes
Intermediate 5 min · September 23, 2026

TimeoutException in Headless: Viewport and UA Fixes

Headless-only timeouts come from tiny viewports, bot blocking, and missing GPU fonts: size the window, set UA, and debug headed..

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 12 min
  • ✓A Selenium suite that passes headed on your machine
  • ✓Access to CI logs or artifacts with failure screenshots
  • ✓Basic Docker or runner-image familiarity for font installs
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • TimeoutException only in headless means the page behaves differently without a display: layout, blocking, or rendering diverge
  • Set an explicit desktop window size — the tiny default viewport hides elements behind mobile layouts and hamburger menus
  • Send a normal user-agent and headed-like flags, since headless signatures invite bot challenges that stall waits
  • Reproduce headed with screenshots to see the real layout, then carry the proven size and flags back to headless
✦ Definition~90s read
What is Selenium TimeoutException in Headless but Not Headed?

TimeoutException from WebDriverWait means a condition never held within its timeout — but in headless-only failures the condition was unreachable, not slow. Headless Chrome historically defaulted to a small viewport around 800 by 600, collapsing responsive pages into mobile layouts where desktop locators match nothing.

★
Picture rehearsing a play on a full stage, then performing it in a closet — same script, but nothing fits and every cue mistimes.

Bot-mitigation scripts also fingerprint headless signatures — missing plugins, webdriver flags, unusual user-agent strings — and answer with challenges, blank pages, or throttled content that stalls every wait simultaneously.

Rendering differences add a third layer. The old headless mode used a separate pipeline without GPU compositing, altering font rendering, canvas behavior, and media loading; lazy-load observers tied to real scrolling sometimes never fired. The new headless mode (--headless=new) shares the full browser pipeline and erased most of these gaps, yet suites pinned to old flags or tiny windows still suffer them.

Viewport, identity, and pipeline together explain nearly every headed-headless divergence.

The professional response equalizes first and debugs second. Set an explicit desktop window size, send a standard user-agent, adopt --headless=new, and pre-install fonts and GPU software fallbacks in CI images. Then reproduce with headed-style evidence: headed runs, screenshots, and DOM dumps from the failing environment.

Most teams find their headless-only flakes were environmental fiction — the application was fine, the invisible stage was not.

Plain-English First

Picture rehearsing a play on a full stage, then performing it in a closet — same script, but nothing fits and every cue mistimes. Headed browsers perform on a full-size stage; headless mode historically performed in a tiny default closet unless you sized it. Elements hide behind mobile menus, lazy images never trigger, and bot detectors treat the closet as suspicious. Measure the closet, widen it to a stage with window-size, and most headless-only failures walk off on their own.

The suite is green on your laptop and red in CI. Same code, same locators, same waits — except CI runs headless, and there every other test dies with TimeoutException. You raise timeouts and the failures move but never leave. The page is not slower in CI; it is a different page: narrower layout, bolder bot defenses, and rendering quirks that only exist without a display.

Headless-only timeouts mislead because waits look like the problem. Engineers tune durations for a layout that will never satisfy the condition — a desktop button that does not exist in the mobile view, a widget blocked by a headless-detecting challenge. No timeout value fixes a condition that is structurally unreachable. The cure is making headless behave like headed, then debugging any remainder with eyes on.

This guide closes the headed-headless gap systematically. You will learn the viewport sizing that restores desktop layouts, the user-agent and flag hygiene that calms bot defenses, the GPU and font differences that stall rendering, and the headed-debug capture that shows you the page CI actually sees. By the end, headless is a deployment detail, not a second test suite.

The Viewport Gap: Size the Invisible Window

The single most common headless-only failure is an unset window size. Without explicit sizing, headless Chrome falls back to a small default viewport that responsive sites read as a phone, serving hamburger menus, stacked layouts, and hidden desktop controls. Your locators target a desktop page that was never rendered — every wait on it is doomed from the first poll, at any timeout value.

The fix is one flag plus verification: --window-size=1920,1080 in the shared driver factory, covering width and height together since responsive breakpoints watch both. Verify by logging driver.get_window_size at session start and screenshotting early failures into build artifacts. Teams that add the size assertion once never revisit this class — the log line makes regressions self-announcing.

Beware half-fixes: set_window_size after load can miss the initial layout pass on some pages, and maximizing a headless window is meaningless without a display manager. Set the size via flags before session start so first paint already uses desktop geometry. The snippet shows the factory shape with the log line. Viewport discipline is unglamorous and decisive — half of all headless mysteries end at this flag.

io_thecodeforge/headless_viewport.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from selenium import webdriver

def make_headless_driver(width=1920, height=1080):
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument(f"--window-size={width},{height}")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    driver = webdriver.Chrome(options=options)
    size = driver.get_window_size()  # prove it in every build log
    print("viewport", size["width"], "x", size["height"])
    assert size["width"] >= 1280, f"viewport too narrow: {size}"
    return driver
📊 Production Insight
A suite burned a month on thirty-second timeouts that a single window-size flag fixed in one commit. The build log had never recorded viewport size, so nobody saw the phone layout. Rule: assert desktop geometry at session start in every headless factory.
🎯 Key Takeaway
Unset headless viewports default small and trigger mobile layouts.
Set --window-size before session start, never maximize headless.
Log and assert viewport size so regressions announce themselves.

User-Agents and Flags That Calm Bot Defenses

Headless browsers carry detectable signatures, and bot-mitigation vendors read them eagerly. Default headless user-agent strings, the webdriver navigator flag, missing plugin arrays, and datacenter IP ranges each add suspicion; combined, they route your tests into challenges, rate limits, or blank responses that look exactly like slow pages. Every wait on a challenged page times out innocently while the real story sits in the screenshot.

Hygiene starts with a standard desktop user-agent matching your pinned Chrome major version — never a phone string, never an ancient desktop one. Prefer --headless=new, which shares the headed pipeline and sheds the oldest detection markers. Keep automation honest: evading defenses with stealth plugins may violate the site's terms and rots with every vendor update. Where defenses persist, the durable answers are test environments with defenses relaxed or vendor-approved test keys, negotiated once and owned explicitly.

The snippet shows the flag and user-agent setup with a challenge detector you can run after load. Check page titles and body markers for challenge text before blaming waits — a five-line detector in setup saves hours of timeout tuning against a page that will never load. Identity first, patience second: no timeout outlasts a block.

io_thecodeforge/headless_identity.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

DESKTOP_UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/126.0.0.0 Safari/537.36")

def make_stealth_honest_driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1920,1080")
    options.add_argument(f"user-agent={DESKTOP_UA}")
    options.add_argument("--disable-blink-features=AutomationControlled")
    return webdriver.Chrome(options=options)

def is_challenged(driver):
    title = driver.title.lower()
    return any(k in title for k in ("just a moment", "attention required",
                                    "access denied", "verify you are"))
📊 Production Insight
A suite timed out on every checkout test for a week while screenshots showed a bot challenge nobody opened. A title detector in setup now fails fast with blocked-by-defense instead. Rule: check for challenges before tuning any timeout.
🎯 Key Takeaway
Headless signatures invite challenges that masquerade as slow pages.
Send a current desktop UA with --headless=new and honest flags.
Detect challenges in setup; negotiate test access instead of evading.

GPU, Fonts, and Rendering That Stall Waits

The old headless pipeline skipped GPU compositing, which changed canvas output, WebGL availability, and animation timing in ways headed runs never showed. Font gaps compound it: minimal CI images ship few typefaces, so text metrics shift, canvas screenshots differ pixel by pixel, and icon fonts render as empty boxes that visibility checks may still accept while visual assertions fail. These are rendering divergences, not speed problems, and patience cannot fix them.

Close the gap in layers. Adopt --headless=new to share the headed pipeline, add software GL fallbacks like --use-gl=swiftshader where the suite needs canvas determinism, and install the font packages your app uses into the CI image so metrics match design machines. Screenshot the same page headed and headless during migration and diff them — residual differences after flags and fonts are the true product bugs worth filing.

The snippet shows the rendering-hardened factory plus a comparison helper for migration week. Treat pixel assertions as environment-sensitive by design: run them against explicit baselines per mode or restrict them to headed lanes. Waits prove states; screenshots prove paint. When paint differs by pipeline, fix the pipeline first and trust the waits again afterward.

io_thecodeforge/headless_render.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from selenium import webdriver

def make_render_ready_driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")  # shares headed pipeline
    options.add_argument("--window-size=1920,1080")
    options.add_argument("--use-gl=swiftshader")  # software GL fallback
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--font-render-hinting=none")
    return webdriver.Chrome(options=options)

# Image prep (Dockerfile): install the app's fonts so metrics match.
#   RUN apt-get update && apt-get install -y fonts-liberation \
#       fonts-dejavu-core fontconfig && fc-cache -f
📊 Production Insight
Canvas assertions failed only in CI because the image lacked the app's icon font — every icon rendered as tofu. Installing fonts-liberation and the icon pack fixed months of visual flakes. Rule: diff headed versus headless screenshots during migration to separate pipeline gaps from product bugs.
🎯 Key Takeaway
--headless=new shares the headed pipeline; old headless diverged.
Install app fonts in CI images so text metrics match design machines.
Treat pixel assertions as environment-sensitive with per-mode baselines.

Lazy Loading and Scroll Observers Without a Display

Infinite scrolls, lazy images, and reveal-on-scroll animations depend on scroll observers that behave differently without a real display. Headed scrolling fires intersection callbacks through the compositor; some headless configurations deliver them late or not at all until scripted scrolling forces the issue. Tests that wait for below-fold content then time out on elements no observer ever revealed — another structurally unreachable condition.

The deterministic answer is scripted scrolling as a first-class helper: scroll the target into view, advance through sentinel positions, and wait for the content marker after each step. Pair each scroll with a short explicit wait on the expected batch rather than one long wait on the final item, so progress is visible step by step. Where the app supports it, test hooks that disable virtualization or preload fixtures remove the observer from the equation entirely — faster and steadier than choreographing scrolls.

The snippet shows a scroll-until-loaded helper with bounded steps and a per-batch wait. Cap the steps so a genuinely broken feed fails in seconds, not at the suite timeout. Log loaded counts per step for evidence. Observers are contracts with the viewport: when the viewport is virtual, drive it explicitly and the content follows.

io_thecodeforge/headless_scroll.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

ITEMS = (By.CSS_SELECTOR, ".feed .card")

def scroll_until(driver, minimum, max_steps=20, timeout=5):
    loaded = 0
    for _ in range(max_steps):
        loaded = len(driver.find_elements(*ITEMS))
        if loaded >= minimum:
            return loaded
        driver.execute_script(
            "window.scrollTo(0, document.body.scrollHeight);")
        try:
            WebDriverWait(driver, timeout).until(
                lambda d: len(d.find_elements(*ITEMS)) > loaded)
        except TimeoutException:
            break  # feed stalled: fail fast with the count below
    assert loaded >= minimum, f"only {loaded} cards loaded"
    return loaded
💡Scroll in Steps With Per-Batch Waits
One long wait on the final lazy item hides where loading stalls. Scroll stepwise and wait per batch — the step where counts stop growing names the broken observer or the feed's real end.
📊 Production Insight
A feed test waited sixty seconds for item fifty while loading stalled at twelve with no error. Stepwise scrolling with per-batch waits exposed the stall point in one run. Rule: bounded scroll loops with counts turn silent stalls into precise failures.
🎯 Key Takeaway
Drive virtual viewports with scripted stepwise scrolling.
Wait per batch so stall points surface instead of hiding.
Bound scroll loops and assert counts for fast, precise failures.

Headed Debug Capture: Seeing What CI Sees

Some divergences survive every flag, and then you need eyes. The headed-debug lane reproduces the CI environment with visibility: run the failing subset headed on identical hardware, or under Xvfb on the CI machine itself, capturing screenshots at each step plus page source at failure. Xvfb provides a virtual display Frederick Douglas would recognize — real geometry, headed pipeline — isolating display-mode effects from machine effects cleanly.

Build capture into the framework so it is always on: screenshots on failure saved to build artifacts, page source alongside, console logs where the app emits them. A failing headless test without artifacts is an unsolved mystery by choice; with artifacts it is a ten-minute read. Name artifacts by test and timestamp so parallel runs never overwrite each other.

The snippet shows a pytest fixture that captures on failure plus the Xvfb command for the diagnostic lane. Keep the diagnostic lane manual or nightly — Xvfb headed runs cost more than pure headless — but keep capture always on. Visibility is a feature of the test system, not a favor developers request after the fact. When CI can show you its screen, headless stops being a black box and starts being just another configuration of the same trusted suite.

io_thecodeforge/debug_capture.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import os
import time
import pytest

ARTIFACTS = "/tmp/ci-artifacts"

@pytest.fixture
def headed_debug(driver, request):
    yield driver  # test runs here
    if request.node.rep_call.failed:
        os.makedirs(ARTIFACTS, exist_ok=True)
        stamp = f"{request.node.name}-{int(time.time())}"
        driver.get_screenshot_as_file(f"{ARTIFACTS}/{stamp}.png")
        with open(f"{ARTIFACTS}/{stamp}.html", "w") as fh:
            fh.write(driver.page_source)

# Diagnostic lane on the CI machine with a virtual display:
#   xvfb-run -a -s "-screen 0 1920x1080x24" pytest tests/ -x -k headless_flake
📊 Production Insight
A headless-only failure survived three weeks until an Xvfb run on the CI host showed a corporate proxy banner invisible in pure headless dumps. Environment, not code. Rule: reproduce on the failing hardware with a virtual display before blaming the suite.
🎯 Key Takeaway
Capture screenshots plus page source on every failure, always on.
Reproduce on CI hardware under Xvfb to isolate display effects.
Keep diagnostics cheap enough to run and rich enough to read.

Converging the Modes: One Suite, Both Displays

The end state is a single suite that passes in both modes, with differences confined to configuration. One driver factory takes a headed flag; CI runs headless with the hardened flags, developers run headed for visibility, and nightly runs sample the opposite mode to catch drift. Mode-specific skips are a code smell — each one concedes a divergence instead of fixing it, and the list grows until the modes are effectively two suites.

Enforce convergence with a mode-matrix job: the same tests, both modes, compared weekly. New divergences file bugs against the environment or the app with screenshots attached, and the matrix stays green by repair rather than exemption. Pin the browser milestone across both so version drift never masquerades as mode drift.

The payoff compounds: developers reproduce CI failures locally by flipping one flag, CI trusts local green builds, and headless becomes what it should be — a display detail. Convergence work feels slow until the month with zero headless-only tickets arrives. The snippet pattern is a factory parameterized by mode plus the matrix invocation. One suite, two displays, zero exemptions: that is the standard that ends headless-only timeouts as a category rather than as individual tickets.

📊 Production Insight
A team ran headless-only for a year until a mode-matrix night exposed six divergences at once, all environmental. Fixing the factory instead of skipping the tests kept one suite instead of two. Rule: sample both modes on a schedule; exemptions are divergence with permission.
🎯 Key Takeaway
Parameterize one factory by mode; never fork the suite per display.
Run a mode matrix on schedule and fix divergences, not skip them.
Pin browser versions across modes so only display varies.
● Production incidentPOST-MORTEMseverity: high

800-Pixel Viewport Hid the Desktop Nav for a Month

Symptom
Roughly half the suite failed in CI with TimeoutException on navigation and menu locators, always green locally. Screenshots showed a mobile hamburger menu where tests expected a desktop nav bar. Raising timeouts from ten to thirty seconds changed nothing — the desktop elements never appeared at any wait length.
Assumption
The team assumed CI was under-provisioned and slow, requesting larger runners twice. When that failed, they suspected a CSS regression and pulled front-end engineers into a debugging war room. The actual page was correct; the browser window was a phone-sized default nobody had configured.
Root cause
The CI driver setup used --headless without any window size, landing on the historic 800x600 default. The responsive app served its mobile layout, where desktop nav locators match nothing by design. Local runs were headed at full monitor size, so the divergence never appeared on laptops — only in the pipeline.
Fix
The shared driver factory gained --window-size=1920,1080 plus --headless=new, applied to every CI run. A startup assertion now screenshots the viewport size into the build log. The suite went green the same evening, and CI runtime dropped because thirty-second timeouts stopped being consumed by impossible conditions.
Key lesson
  • Headless defaults are not headed defaults — an unset window size is a phone-sized lie about your layout.
  • Timeouts that survive tripling are unreachable conditions, not slow pages — stop tuning and inspect the layout.
  • Log viewport size at session start; layout bugs become one-line comparisons instead of war rooms.
Production debug guideFive steps that make the invisible browser visible.5 entries
Symptom · 01
Tests pass headed locally but time out headless in CI
→
Fix
Screenshot the CI viewport first: save driver.get_screenshot_as_file to the build artifacts and read the actual layout. Check the window size in the same run. A mobile layout or tiny canvas confirms the viewport gap — set --window-size=1920,1080 and rerun before touching timeouts.
Symptom · 02
Screenshots show challenges, blanks, or access-denied pages
→
Fix
Bot defenses flagged the headless signature. Set a standard desktop user-agent string, adopt --headless=new, and drop naïve automation flags. Verify by loading the page headed with identical flags — if the challenge follows the flags, identity is the cause, not speed.
Symptom · 03
Text and canvas checks fail only in headless
→
Fix
Suspect fonts and GPU: list installed fonts with fc-list in the CI image and compare against your laptop. Install the missing font packages and add software rendering fallbacks. Re-run a single canvas test headed versus headless with screenshots to confirm rendering parity.
Symptom · 04
Lazy-loaded content never appears in headless runs
→
Fix
Scroll programmatically through the page with repeated scrollIntoView calls, screenshotting between scrolls. Observers tied to real scrolling may need the encouragement. If content loads on scroll headed but never headless, carry the scroll sequence into a shared helper rather than extending waits.
Symptom · 05
You need headed evidence from a headless-only runner
→
Fix
Run headed under Xvfb on the CI machine itself: xvfb-run with the same suite subset, capturing screenshots and page source. Identical hardware with a virtual display isolates display-mode effects from machine effects. Keep the Xvfb job as a diagnostic lane, not the default.
Headless-Only Timeout Causes Compared
Root CauseHow to ConfirmFixPrevention
Default tiny viewport serving mobile layoutScreenshots show hamburger menus; size logs 800x600--window-size=1920,1080 in factory; assert geometry at startViewport assertion in every headless session log
Bot defenses challenging headless identityChallenge or denied titles in screenshots and sourceDesktop UA with --headless=new; negotiated test accessChallenge detector in setup failing fast with a clear reason
Missing fonts and GPU pipeline gapsTofu icons and canvas diffs only in headless imagesInstall app fonts; software GL fallback; new headless modeHeaded-versus-headless screenshot diff during migration
Scroll observers never firing without a displayBelow-fold content absent at any timeout lengthScripted stepwise scrolling with per-batch waitsFeed test hooks or preload fixtures in test environments
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
io_thecodeforgeheadless_viewport.pyfrom selenium import webdriverThe Viewport Gap
io_thecodeforgeheadless_identity.pyfrom selenium import webdriverUser-Agents and Flags That Calm Bot Defenses
io_thecodeforgeheadless_render.pyfrom selenium import webdriverGPU, Fonts, and Rendering That Stall Waits
io_thecodeforgeheadless_scroll.pyfrom selenium.common.exceptions import TimeoutExceptionLazy Loading and Scroll Observers Without a Display
io_thecodeforgedebug_capture.pyARTIFACTS = "/tmp/ci-artifacts"Headed Debug Capture

Key takeaways

1
Unreachable conditions, not slow pages, cause most headless-only timeouts.
2
Set explicit desktop viewport size before session start and assert it in logs.
3
Use --headless=new with a current desktop user-agent and honest flags.
4
Install app fonts and GL fallbacks in CI images for rendering parity.
5
Drive lazy content with stepwise scrolling and per-batch waits.
6
Capture artifacts on every failure; reproduce under Xvfb for visibility.

Common mistakes to avoid

5 patterns
×

Tripling timeouts on structurally unreachable conditions

Symptom
Thirty-second waits fail identically to ten-second ones while CI runtime balloons, because the element cannot exist in the served layout.
Fix
Screenshot first and confirm the layout. Size the viewport or fix identity, then let the original timeout stand — it was never the problem.
×

Running old --headless without a window size

Symptom
Mobile layouts, stale detection markers, and rendering gaps combine into failures that vanish the moment anyone runs headed.
Fix
Adopt --headless=new with explicit --window-size in the shared factory. Old flags plus defaults are a legacy failure bundle.
×

Copying phone user-agents from debugging blogs

Symptom
The app serves touch layouts and tap targets while desktop locators fail, converting an identity fix into a second layout bug.
Fix
Send a current desktop UA matching the pinned Chrome major. Identity should restore the desktop page, never summon the mobile one.
×

Skipping headless-failing tests instead of fixing the factory

Symptom
Skip lists grow until headless and headed are effectively two suites, and real layout bugs hide behind exemptions nobody revisits.
Fix
Fix the environment — viewport, identity, fonts — and keep a mode matrix that repairs divergences instead of permitting them.
×

Debugging headless failures without artifacts

Symptom
Engineers re-run blindly and guess at layouts they cannot see, while the same failure with a screenshot would take ten minutes.
Fix
Capture screenshots plus page source on every failure into build artifacts. Visibility is framework infrastructure, not optional diligence.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Why do tests pass headed but time out headless?
Q02JUNIOR
What viewport should headless tests use and why?
Q03SENIOR
How do bot defenses cause TimeoutException?
Q04SENIOR
When is raising the timeout the wrong move?
Q05SENIOR
How do you reproduce a headless-only failure with visibility?
Q01 of 05SENIOR

Why do tests pass headed but time out headless?

ANSWER
The page differs by mode: default headless viewports serve mobile layouts, bot defenses challenge headless identity, and rendering pipelines diverge on fonts and GPU. The waited condition is often unreachable rather than slow. Equalize viewport, identity, and pipeline first, then debug leftovers with screenshots.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What does --headless=new change versus old headless?
02
Should CI run headed under Xvfb instead of headless?
03
Do I need --no-sandbox and --disable-dev-shm-usage?
04
Why do screenshots show a different layout than my laptop?
05
Can device emulation replace window-size flags?
06
How do I keep headed and headless from drifting apart?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Selenium. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Selenium SessionNotCreatedException: Driver Version Mismatch
5 / 5 · Selenium
Next
Cypress cy.visit Failed — Server Not Running
→