ScreenshotNeo

BlogHow-to

How to Fix Python Selenium Repeating the Same Element Screenshot in a Loop

Fix repeated Selenium screenshots by changing browser state, waiting for the transition, re-locating elements, and saving unique filenames.

By the ScreenshotNeo team30 September 202610 min read

How to Fix Python Selenium Repeating the Same Element Screenshot in a Loop

Direct fix: a loop counter alone does not change what Selenium sees. Each iteration must perform the state change that selects the next item, wait for that change to finish, locate the current element again, and write to a filename that cannot be reused.

from pathlib import Path

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


driver = webdriver.Chrome()
wait = WebDriverWait(driver, 10)
out = Path("screenshots")
out.mkdir(exist_ok=True)

try:
    driver.get("https://example.com/products")

    # Keep the locator, not WebElement objects, for reuse after DOM updates.
    item_locator = (By.CSS_SELECTOR, ".item")
    items = driver.find_elements(*item_locator)

    for index in range(len(items)):
        # Locate the current node after any navigation, click, refresh, or update.
        current = wait.until(
            EC.visibility_of_element_located(
                (By.CSS_SELECTOR, f".item:nth-of-type({index + 1})")
            )
        )

        driver.execute_script(
            "arguments[0].scrollIntoView({block: 'center'});", current
        )
        current.screenshot(str(out / f"item-{index:03d}.png"))
finally:
    driver.quit()

1. Understand why the same image is saved

Selenium captures the current browser window or the element represented by the current WebElement. It does not infer that index means “show the next item.” Repeated output usually comes from one of six causes:

  1. Browser state never changes. The URL, selected tab, modal, pagination page, or component remains the same.
  2. An element was cached too early. A refresh or JavaScript framework replacement makes the old reference stale, or leaves you operating on an object from an earlier state.
  3. The selector always finds the first match. find_element returns one match. A changing loop variable has no effect unless it appears in the locator or the interaction.
  4. Rendering is asynchronous. The screenshot runs before the new content, image, or selected state appears.
  5. The output path is reused. Every capture overwrites the same file.
  6. The capture scope is wrong. driver.save_screenshot() captures the window, while element.screenshot() captures the located element.

Before each capture, log the loop index, target text or identifier, current URL, and output path. If those values do not change when expected, the screenshot code is only exposing an earlier state problem.

2. Choose the correct loop pattern

Capture every element without navigation

Use a stable attribute when possible. Positional selectors such as :nth-of-type() can change when advertisements, headers, or other nodes are inserted.

A reliable screenshot loop changes state, waits, re-locates the element, and writes a unique file.
A reliable screenshot loop changes state, waits, re-locates the element, and writes a unique file.
from pathlib import Path
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

wait = WebDriverWait(driver, 10)
out = Path("screenshots")
out.mkdir(exist_ok=True)

# Read stable identifiers first, then locate each item by identifier.
ids = [
    element.get_attribute("data-id")
    for element in driver.find_elements(By.CSS_SELECTOR, ".item[data-id]")
]

for index, item_id in enumerate(ids):
    locator = (By.CSS_SELECTOR, f'.item[data-id="{item_id}"]')
    element = wait.until(EC.visibility_of_element_located(locator))
    element.screenshot(str(out / f"item-{item_id}-{index:03d}.png"))

Click each item, wait for a detail view, then capture

Perform the action before locating the detail element. Wait on a signal that proves the transition completed: a URL change, a heading, a spinner disappearing, or the old node becoming stale.

from pathlib import Path
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

wait = WebDriverWait(driver, 15)
out = Path("details")
out.mkdir(exist_ok=True)

cards = driver.find_elements(By.CSS_SELECTOR, ".product-card")
for index in range(len(cards)):
    # Re-find the card because the previous iteration may have changed the DOM.
    card = wait.until(EC.element_to_be_clickable(
        (By.CSS_SELECTOR, f".product-card:nth-of-type({index + 1})")
    ))
    name = card.get_attribute("data-name") or f"product-{index:03d}"
    old_heading = driver.find_element(By.CSS_SELECTOR, "h1")
    card.click()

    wait.until(EC.staleness_of(old_heading))
    heading = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "h1")))
    safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
    heading.screenshot(str(out / f"{index:03d}-{safe_name}.png"))

    driver.back()
    wait.until(EC.visibility_of_element_located(
        (By.CSS_SELECTOR, ".product-card")
    ))

Paginate or select a tab

When the next item is on another page or tab, wait for the old state to disappear and the new state to appear. Do not assume that a click has finished because the command returned.

from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC

for page_number in range(1, 4):
    old_marker = wait.until(EC.visibility_of_element_located(
        (By.CSS_SELECTOR, ".results[data-page]")
    ))
    old_page = old_marker.get_attribute("data-page")

    page_items = wait.until(EC.visibility_of_all_elements_located(
        (By.CSS_SELECTOR, ".result-card")
    ))
    for item_index in range(len(page_items)):
        item = wait.until(EC.visibility_of_element_located(
            (By.CSS_SELECTOR, f".result-card:nth-of-type({item_index + 1})")
        ))
        item.screenshot(f"screenshots/page-{page_number:02d}-item-{item_index:03d}.png")

    if page_number < 3:
        driver.find_element(By.CSS_SELECTOR, "button.next").click()
        wait.until(lambda d: d.find_element(
            By.CSS_SELECTOR, ".results[data-page]"
        ).get_attribute("data-page") != old_page)

3. Synchronize with explicit waits

Selenium describes explicit waits as polling for a specific condition before continuing. Select a condition tied to the transition you need:

Situation Condition
Element was inserted and can be seen visibility_of_element_located
Button can be used element_to_be_clickable
New text identifies the selected item text_to_be_present_in_element
Navigation finished url_contains or url_to_be
Old framework node was replaced staleness_of(old_element)
Loading indicator finished invisibility_of_element_located

Prefer a condition over time.sleep(). A fixed sleep may be too short on a slow run and wastes time on a fast run. Avoid mixing implicit and explicit waits because their polling delays can interact unpredictably.

# Wait for a selected identifier, not an arbitrary delay.
wait.until(EC.text_to_be_present_in_element(
    (By.CSS_SELECTOR, ".selected-item"),
    expected_label,
))

# Wait until a loading overlay no longer blocks the target.
wait.until(EC.invisibility_of_element_located(
    (By.CSS_SELECTOR, ".loading-overlay")
))

4. Handle stale elements correctly

StaleElementReferenceException means the element is no longer attached to the DOM. Refreshes and JavaScript frameworks that remove and re-add nodes commonly cause it. Keep locator tuples and call find_element inside the loop.

Waiting for the old node to become stale helps prove that the replacement element is ready.
Waiting for the old node to become stale helps prove that the replacement element is ready.
from selenium.common.exceptions import StaleElementReferenceException

locator = (By.CSS_SELECTOR, ".item[data-id='42']")
for attempt in range(3):
    try:
        element = wait.until(EC.visibility_of_element_located(locator))
        element.screenshot("screenshots/item-42.png")
        break
    except StaleElementReferenceException:
        if attempt == 2:
            raise
        # The next iteration re-locates the replacement node.
        continue

Use staleness_of when the old node's disappearance is the proof that replacement has happened. Do not catch stale exceptions broadly and continue forever; a bounded retry exposes a real page or locator problem.

5. Capture the intended scope

  • driver.save_screenshot(path) captures the current browser viewport.
  • element.screenshot(path) captures the located element.
  • For a full page, scroll or use the browser's full-page capability deliberately; an element loop does not create full-page images automatically.
# Window screenshot
Path("screenshots").mkdir(exist_ok=True)
driver.save_screenshot("screenshots/window.png")

# Element screenshot
card = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, ".card")))
card.screenshot("screenshots/card.png")

6. Make filenames unique and verifiable

Include an index plus a stable business identifier when available. Check that the path changes and that a file exists after each write.

from pathlib import Path

path = Path("screenshots") / f"item-{index:03d}-{item_id}.png"
element.screenshot(str(path))
if not path.is_file() or path.stat().st_size == 0:
    raise RuntimeError(f"Screenshot was not written: {path}")

Sanitize identifiers before putting them in filenames. Also check that the output directory is writable and that different normalized names are not collapsing to the same path.

7. Debug a loop that still repeats

def describe(element):
    return {
        "text": element.text[:100],
        "data_id": element.get_attribute("data-id"),
        "url": driver.current_url,
    }

for index in range(expected_count):
    element = wait.until(EC.visibility_of_element_located(
        (By.CSS_SELECTOR, f".item:nth-of-type({index + 1})")
    ))
    path = Path("screenshots") / f"item-{index:03d}.png"
    print({"index": index, "target": describe(element), "path": str(path)})
    element.screenshot(str(path))

Compare the printed values:

  • If the text, identifier, and URL never change, add the missing click, navigation, selection, or pagination action.
  • If the identifier changes but the image does not, wait for the content inside the element to render or confirm that you are using the correct scope.
  • If the path repeats, fix the filename construction before investigating Selenium.
  • If locating fails after a frame change, switch into the correct iframe and return to default content before the next unrelated page.

8. Common errors and fixes

Error or symptom Likely cause Fix
Every file looks identical No browser state change or selector always targets the first node Log target identity; use indexed or stable-attribute locators and perform the transition before capture
StaleElementReferenceException Refresh or framework replacement detached the node Wait for staleness, then locate again from a locator tuple
NoSuchElementException Element is not yet present, wrong frame, or wrong selector Use an explicit wait, inspect the selector, and switch to the correct iframe
ElementClickInterceptedException Overlay, popup, or animation blocks the click Wait for the overlay to disappear, then wait for clickability
Timeout waiting for a new page Condition does not describe the actual transition Wait for URL, heading, text, spinner disappearance, or staleness that really changes
Only the viewport is captured Window screenshot used when an element or full-page capture was intended Choose element.screenshot or an intentional full-page method
Files overwrite each other Constant or sanitized-collision filename Use an index and stable identifier, then verify paths

9. Performance, reliability, and cost

Re-locating elements is usually cheaper than debugging corrupted output. Keep waits scoped to the transition, avoid repeated full-page screenshots when an element shot is enough, and capture only after the target is stable. Stable data attributes are generally more reliable than positional selectors when the DOM can reorder.

For large batches, record failures with the URL, item identifier, exception, and output path. Retry bounded transient failures, but preserve the original error after the retry limit. Keep browser and driver versions compatible, and close the driver in a finally block so a failed iteration does not leak a process.

Local Selenium uses your browser and machine resources. A hosted screenshot API can remove browser setup and provide a usage-based cost model. ScreenshotNeo has a free plan for 1,000 shots per month; paid plans start at $5 for 3,000 shots, with every feature on every plan.

10. Or skip the browser setup

ScreenshotNeo provides a GET-based website screenshot API. The request below returns an image for a URL; see the ScreenshotNeo API documentation for options such as element selectors, full-page capture, waits, custom JavaScript, headers, cookies, device presets, PDFs, caching, async jobs, and bulk capture.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. Create a free account for 1,000 screenshots each month with no card; paid plans start at $5 for 3,000.

11. Practical checklist

  • Does each iteration perform the action that selects a different item?
  • Is the element located after navigation, refresh, click, or DOM replacement?
  • Does the wait prove the specific transition completed?
  • Are you using a stable attribute instead of an accidental first match?
  • Are you in the correct iframe or window?
  • Are you intentionally capturing the window or the element?
  • Does every output path include a unique index or identifier?
  • Do logs show a changing target, URL, and path?
  • Are retries bounded and the driver always closed?

12. FAQ

Why does changing the Python index not change the screenshot?

Because Python variables do not alter browser state. Use the index in a locator, click the indexed item, navigate to the indexed URL, or select the indexed tab before waiting and capturing.

Should I store all WebElement objects before the loop?

Only when the DOM is guaranteed not to change. After navigation, refreshes, clicks, or framework updates, store locators and re-find the current element.

Is time.sleep(2) enough?

It can hide a race temporarily but does not prove that the required state exists. Wait for visibility, text, URL, clickability, staleness, or loading completion.

How do I capture an element inside an iframe?

Wait for and switch to the frame, locate and capture the element inside it, then call driver.switch_to.default_content() before working with the outer page.

Why is a detail screenshot still the old card?

The click may not have completed, the old node may still be present, or the code may be capturing a cached reference. Wait for a URL or heading change, old-node staleness, and a newly located detail element.