PRACTICAL GUIDE / debug Selenium Python WebDriver session leaks

Find the pytest fixture that leaves browsers running

Trace Selenium session IDs back to the pytest fixture that created them, guarantee quit runs after setup failures, and prove cleanup on Grid.

By The Testing AcademyUpdated August 4, 20269 min read
All field guides
In this guide6 sections
  1. Why a browser can outlive its test
  2. Put cleanup next to session creation
  3. Prove which session was leaked
  4. Fix the owner, then make failure visible
  5. Account for crashes and parallel workers
  6. When process cleanup is the wrong fix

What you will learn

  • Why a browser can outlive its test
  • Put cleanup next to session creation
  • Prove which session was leaked
  • Fix the owner, then make failure visible

A pytest run finishes, but Chrome windows keep accumulating on the worker. A few builds later, Selenium Grid has no free slots even though the test report is green. That is not a browser problem until the session lifecycle proves it is one.

Why a browser can outlive its test

WebDriver creation allocates more than a Python object. A local webdriver.Chrome() starts a driver service and a browser process. webdriver.Remote() asks Grid for a slot and creates a remote session. The session remains owned by that client until quit() reaches the remote end or the infrastructure eventually reclaims it.

Python garbage collection is not a teardown strategy. Losing the last reference to driver does not provide a timely, reliable WebDriver Delete Session command. Closing one tab is not equivalent either. driver.close() closes the current top-level browsing context; driver.quit() ends the session, closes associated windows, stops local background processes, and tells Grid that the slot can be reused.

The common pytest leak sits in fixture setup, not in the test body:

Python
@pytest.fixture
def driver():
    browser = webdriver.Chrome()
    configure_downloads(browser)  # raises here
    yield browser
    browser.quit()

If configure_downloads() raises, execution never reaches yield. Pytest therefore has not entered the teardown portion after that yield. The browser already exists, but no cleanup has been registered for it.

A test assertion failure after yield is different. Pytest unwinds fixtures and executes their teardown code. This distinction explains why leak reports often point at failed setup, skipped tests, helper fixtures, or partially constructed page objects rather than ordinary assertion failures.

Scope mistakes create a second class of symptoms. A session-scoped driver will correctly remain alive after one test because the fixture owns it until the session ends. A function-scoped driver should not. Before calling anything a leak, identify the fixture scope and the moment at which its owner is supposed to release it.

Put cleanup next to session creation

The safest fixture registers finalization as soon as WebDriver creation returns. Later setup may fail, but pytest already knows how to release the acquired resource. Keep application setup in other fixtures so the driver fixture has one job.

The following conftest.py works with a local Chrome installation or a Grid URL supplied through SELENIUM_REMOTE_URL:

Python
import logging
import os
from collections.abc import Iterator

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.remote.webdriver import WebDriver

log = logging.getLogger(__name__)


@pytest.fixture
def driver(request: pytest.FixtureRequest) -> Iterator[WebDriver]:
    options = Options()
    options.add_argument("--headless=new")

    grid_url = os.getenv("SELENIUM_REMOTE_URL")
    browser = (
        webdriver.Remote(command_executor=grid_url, options=options)
        if grid_url
        else webdriver.Chrome(options=options)
    )

    session_id = browser.session_id or "<unknown>"
    worker = os.getenv("PYTEST_XDIST_WORKER", "main")
    log.info(
        "webdriver-start nodeid=%s worker=%s session_id=%s",
        request.node.nodeid,
        worker,
        session_id,
    )

    def stop_browser() -> None:
        log.info(
            "webdriver-quit-start nodeid=%s worker=%s session_id=%s",
            request.node.nodeid,
            worker,
            session_id,
        )
        try:
            browser.quit()
        except Exception:
            log.exception(
                "webdriver-quit-failed nodeid=%s worker=%s session_id=%s",
                request.node.nodeid,
                worker,
                session_id,
            )
            raise
        log.info(
            "webdriver-quit-ok nodeid=%s worker=%s session_id=%s",
            request.node.nodeid,
            worker,
            session_id,
        )

    request.addfinalizer(stop_browser)
    yield browser

A small test is enough to exercise both creation and teardown:

Python
from selenium.webdriver.remote.webdriver import WebDriver


def test_checkout_title(driver: WebDriver) -> None:
    driver.get("data:text/html,<title>Checkout</title><h1>Ready</h1>")
    assert driver.title == "Checkout"

Run only that node while preserving the lifecycle logs:

Shell
pytest -q -s --log-cli-level=INFO tests/test_checkout.py::test_checkout_title

For a simple fixture that creates the driver and immediately yields it, the shorter yield pattern is idiomatic. Direct finalizers earn their extra code when setup after acquisition can fail. The trade-off is readability: finalizer-heavy fixtures become difficult to reason about, especially when several callbacks depend on one another. Split each acquired resource into its own fixture instead of building a stack of anonymous cleanup functions.

Prove which session was leaked

Process counts show pressure, but they do not establish ownership. On a shared runner, a chromedriver process may belong to another job. On Grid, a nonzero session count may be completely healthy. The useful unit of evidence is the session ID tied to a pytest node ID and worker.

For Selenium Grid 4, query active sessions through the documented GraphQL endpoint:

Shell
curl -s -X POST \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ sessionsInfo { sessions { id startTime nodeUri } } }"}' \
  http://localhost:4444/graphql

Capture the response before the focused test and again after pytest exits. Find the session_id from webdriver-start in the second response. A matching live ID with no webdriver-quit-start record points to missing fixture finalization or abrupt process termination. A webdriver-quit-failed record points to a transport, Grid, or node problem. A webdriver-quit-ok record followed by the same live Grid session requires server-side investigation because client and server disagree about deletion.

The exception location matters too. Use pytest --setup-show on the focused node to see fixture setup and teardown order:

Shell
pytest -q --setup-show tests/test_checkout.py::test_checkout_title

If the test never starts, inspect the last fixture whose setup began. If teardown begins but the driver fixture is absent, the test may be using a different fixture than the one the team reviewed. Search for every webdriver.Chrome(, webdriver.Firefox(, and webdriver.Remote( call. A helper that creates a second driver has created a second owner, whether or not it is named "driver factory."

Local evidence needs the same discipline. Record the driver service process ID when your binding exposes it only if your framework truly needs process diagnostics, but treat the WebDriver session ID as the portable identifier. A process snapshot taken minutes later cannot connect a browser to a particular test without that earlier correlation.

Fix the owner, then make failure visible

Move each WebDriver constructor into a fixture or factory with an explicit lifetime. The code that creates a session must also arrange its deletion. Returning a bare driver from a general helper invites callers to forget cleanup.

Tests that need two browsers, for example a buyer and an administrator, need one cleanup registration per successful creation. A factory that stores only its most recent driver leaks the earlier sessions. Register each instance as it is returned and let pytest unwind the finalizers in reverse order:

Python
@pytest.fixture
def driver_factory(request: pytest.FixtureRequest):
    def create() -> WebDriver:
        browser = webdriver.Chrome()
        session_id = browser.session_id

        def stop() -> None:
            log.info("webdriver-quit-start session_id=%s", session_id)
            browser.quit()
            log.info("webdriver-quit-ok session_id=%s", session_id)

        request.addfinalizer(stop)
        return browser

    return create

Capture the session ID before cleanup because bindings may clear it after a successful quit(). For a multi-browser test, add a role such as buyer or admin to the creation log. That makes two legitimate sessions distinguishable without relying on creation order.

Do not catch and suppress exceptions from quit(). A cleanup failure should appear as a teardown error even when the product assertion passed. Otherwise the report says green while the next job pays for exhausted ports, memory, or Grid capacity. Logging the session ID before the call preserves evidence even if the connection dies during deletion.

Keep cleanup separate from screenshot capture and report attachment. A failing screenshot command must not prevent quit(). If an evidence fixture depends on the driver fixture, pytest will normally tear it down first. When one finalizer performs both tasks, put quit() in a finally block and preserve both errors rather than letting the first one erase the second.

Also match fixture scope to isolation policy. Function scope costs browser startup time but gives every test a clean session and a narrow ownership trail. Class, module, or session scope reduces startup cost but shares cookies, windows, storage, and failure state. Long-lived sessions are not leaks, but they make leaks and test contamination harder to separate.

Account for crashes and parallel workers

No fixture can run after the Python process is forcibly killed, the container is terminated without grace, or the machine loses power. That boundary belongs to the runner and Grid. Configure infrastructure reclamation as a safety net, then keep client teardown as the primary path.

Parallel pytest workers make totals misleading. Each xdist worker is a separate process with its own fixtures, so include the worker name in every session log. Compare the number of start and successful quit records per worker. If a worker crashes, its last session ID tells Grid operators exactly what remained.

There is an awkward edge case during remote creation. Grid can allocate a session, then the response can be lost before Python receives the session ID or a usable driver object. The fixture cannot call quit() on an object it never obtained. Grid observability, node logs, and infrastructure timeouts are the only reliable evidence for that case. Do not mislabel it as a missing pytest finalizer.

Retries require fresh accounting. Attempt one may leak a browser and attempt two may pass, producing a green test beside an extra active session. Add the attempt identifier to the node ID or structured log context, and require a release record for every successful creation, not merely for the final attempt.

A fixture timeout deserves the same treatment as a worker crash. Some CI wrappers terminate the test process as soon as a wall-clock limit expires, leaving pytest no chance to unwind. Give pytest a shorter internal timeout than the runner's hard limit so failure reporting and finalizers have time to run. The remaining grace period costs a little CI time, but it prevents the infrastructure watchdog from becoming the normal teardown path.

When process cleanup is the wrong fix

Killing every Chrome or driver process after a job hides the ownership bug and can terminate sessions belonging to neighboring jobs. It also turns a clean WebDriver shutdown into an operating-system cleanup, so Grid may retain a slot until it notices the dead node or session. Use targeted process termination only as incident recovery on an isolated worker.

Do not add quit() to both the test and its fixture. Double ownership creates InvalidSessionIdException noise and makes teardown behavior depend on which call wins. Tests consume a driver; the owning fixture releases it.

Avoid changing every fixture to function scope solely because a session is visible between tests. Confirm the intended lifetime first. A deliberate session-scoped browser should be judged after the session teardown boundary, not after each test.

Finally, do not raise Grid timeouts or add more slots before measuring leaked IDs. Extra capacity delays the outage but preserves the defect. A useful fix produces one creation record, one cleanup attempt, and no matching active session after the owner has finished.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

How do I know whether pytest leaked a Selenium session?

A leak is confirmed when a session created for a finished test is still active on Grid, or its browser and driver processes remain locally. Match the WebDriver session ID in the creation log to the same ID after pytest has completed.

Where should driver.quit() go in a pytest fixture?

Register the cleanup immediately after WebDriver creation succeeds. A yield fixture is fine when nothing between creation and yield can fail; otherwise, request.addfinalizer() closes the setup gap.

Can driver.close() replace driver.quit() in teardown?

No. close() targets the current window, while quit() ends the WebDriver session, closes its windows, and tells Grid the slot is free. Teardown should call quit() once on the fixture that owns the driver.

How can I list sessions that are still running on Selenium Grid?

Query Grid's GraphQL endpoint for session IDs and compare the response before and after the test. On a shared Grid, correlate IDs rather than expecting the total session count to reach zero.

Should teardown ignore an exception from driver.quit()?

Treat a failed quit as a teardown failure and keep its session ID in the logs. Swallowing the exception turns a resource leak into a green build and removes the clue an infrastructure team needs.