Skip to main content
SeleniumDecoded

Speeding Up Test Suites

Where Selenium suites lose time and how to get it back: measure first, then cut command count, skip UI you are not testing, fix waits, parallelise, block third-party traffic, and shrink the suite itself.

Selenium 4 Stable Updated 9 Sept 2026 · Verified against Selenium 4.48.0

A 40-minute suite gets run nightly; a 6-minute suite gets run on every pull request. Speed changes how a suite is used, which changes how many bugs it catches. Most slow Selenium suites are slow for the same handful of reasons, and none of them is “Selenium is slow.” This lesson is the ordered list of fixes, biggest payoff first, with the measurement that tells you which apply to you.

Measure Before You Optimise

Instrument three numbers per test: wall time, number of WebDriver commands, and time spent inside waits. A command counter is a five-line listener; the ratio of wait time to total time tells you whether you are waiting on the app or on your own sleeps.

Count commands and time them
Selenium 4 Stable
// Selenium 4 event listener wraps any driver
import org.openqa.selenium.support.events.EventFiringDecorator;
import org.openqa.selenium.support.events.WebDriverListener;
public class CommandTimer implements WebDriverListener {
private final AtomicInteger commands = new AtomicInteger();
private final AtomicLong nanos = new AtomicLong();
private long start;
@Override public void beforeAnyCall(Object target, Method method, Object[] args) { start = System.nanoTime(); }
@Override public void afterAnyCall(Object target, Method method, Object[] args, Object result) {
commands.incrementAndGet();
nanos.addAndGet(System.nanoTime() - start);
}
public String report() {
return String.format("%d commands, %.1f s in driver calls", commands.get(), nanos.get() / 1e9);
}
}
CommandTimer timer = new CommandTimer();
WebDriver driver = new EventFiringDecorator<>(timer).decorate(new ChromeDriver());
// ... test ...
System.out.println(timer.report());
import time
from selenium.webdriver.support.abstract_event_listener import AbstractEventListener
from selenium.webdriver.support.event_firing_webdriver import EventFiringWebDriver
class CommandTimer(AbstractEventListener):
def __init__(self):
self.commands = 0
self.seconds = 0.0
self._start = 0.0
def before_find(self, by, value, driver): self._start = time.perf_counter()
def after_find(self, by, value, driver):
self.commands += 1
self.seconds += time.perf_counter() - self._start
# Also implement before_click/after_click, before_navigate_to/after_navigate_to, etc.
timer = CommandTimer()
driver = EventFiringWebDriver(webdriver.Chrome(), timer)
# ... test ...
print(f"{timer.commands} commands, {timer.seconds:.1f} s in driver calls")
# Simpler: wrap driver.execute at the lowest level
original = driver.wrapped_driver.execute
def timed_execute(command, params=None):
t = time.perf_counter()
try: return original(command, params)
finally: print(f"{command}: {(time.perf_counter() - t) * 1000:.0f} ms")
driver.wrapped_driver.execute = timed_execute
// Wrap the executor: every command passes through driver.execute
const { Command } = require('selenium-webdriver/lib/command');
let commands = 0, totalMs = 0;
const originalExecute = driver.execute.bind(driver);
driver.execute = async (command) => {
const t = Date.now();
try { return await originalExecute(command); }
finally { commands++; totalMs += Date.now() - t; }
};
// ... test ...
console.log(`${commands} commands, ${(totalMs / 1000).toFixed(1)} s in driver calls`);
// EventFiringWebDriver in OpenQA.Selenium.Support.Events
using OpenQA.Selenium.Support.Events;
var timer = Stopwatch.StartNew();
int commands = 0;
var efd = new EventFiringWebDriver(new ChromeDriver());
efd.FindingElement += (_, _) => commands++;
efd.ElementClicking += (_, _) => commands++;
efd.Navigating += (_, _) => commands++;
// ... test with efd ...
Console.WriteLine($"{commands} commands in {timer.Elapsed.TotalSeconds:F1} s");

Run the suite once with this on and sort tests by time. The top 10% of tests usually hold half the run time.

1. Stop Testing Login Through the UI

The single biggest win in most suites. If 300 tests each spend 8 seconds logging in, that is 40 minutes of the same screen. Log in once and reuse the session via cookies or a token in storage, or call the login API and set the cookie. See Cookies, Local Storage and Session Storage.

The same applies to any setup that is not the subject of the test: creating a product, adding items to a basket, accepting cookie banners. Do it through the API or a database seed; use the browser only for the behaviour under test.

2. Fix the Waits

  • Remove every sleep. A sleep(3) that “makes it stable” adds 3 seconds to every run, even when the app was ready in 200 ms.
  • Implicit wait to zero. Combined with explicit waits it makes every negative check (waiting for an element to disappear) take the full implicit timeout.
  • pageLoadStrategy = eager for single-page apps. See Page Load Strategy and Timeouts.
  • Poll faster for cheap conditions: 100 to 200 ms instead of the default 500 ms saves up to 400 ms per wait.

3. Cut Command Round Trips

Every command is an HTTP round trip (single-digit ms locally, tens of ms to a Grid or cloud). Tests that read 50 table cells with 50 getText calls can read them with one script.

One script instead of N commands
Selenium 4 Stable
// SLOW: 2 commands per row (findElement + getText), 100 rows = 200 round trips
List<WebElement> cells = driver.findElements(By.cssSelector("table.orders td.total"));
List<String> totals = new ArrayList<>();
for (WebElement cell : cells) totals.add(cell.getText());
// FAST: one round trip
List<String> totals = (List<String>) ((JavascriptExecutor) driver).executeScript(
"return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())");
# SLOW
totals = [cell.text for cell in driver.find_elements(By.CSS_SELECTOR, "table.orders td.total")]
# FAST
totals = driver.execute_script(
"return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())")
// SLOW
const cells = await driver.findElements(By.css('table.orders td.total'));
const totals = await Promise.all(cells.map((c) => c.getText()));
// FAST
const totals = await driver.executeScript(
"return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())");
// SLOW
var totals = driver.FindElements(By.CssSelector("table.orders td.total")).Select(c => c.Text).ToList();
// FAST
var totals = ((IJavaScriptExecutor)driver).ExecuteScript(
"return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())")
as IReadOnlyCollection<object>;

Other round-trip savers: scope searches to a container element, avoid findElements polling loops in favour of a single wait condition, and assert with one getAttribute("outerHTML") when you need several properties of one element.

4. Block Third-Party Traffic

Analytics, chat widgets, fonts from CDNs, A/B testing scripts and ad pixels can double page load time and add nondeterminism. Block them at the network layer with BiDi; the app under test is unaffected and every page loads faster. See Network Interception with BiDi. Chrome’s --host-resolver-rules flag is a cruder alternative: --host-resolver-rules=MAP *.doubleclick.net 127.0.0.1.

5. Parallelise, Then Shard

After the per-test fixes, run tests concurrently. On one machine, one browser per 2 vCPUs is a safe density; beyond that, a Grid or CI matrix. Order matters: parallelising a suite with shared state or sleeps multiplies the flakiness. See Parallel Execution and CI/CD Pipelines.

Also balance the shards. If one job runs the 10 slowest tests, the pipeline waits for it. Split by historical duration, not by file count.

6. Headless, Sized, and Close to the App

  • Headless Chrome (--headless=new) renders faster and uses less memory than headed.
  • Set the window size once; do not maximize every test.
  • Run the Grid or browsers in the same network as the application under test. A test runner in one cloud region driving browsers in another, hitting an app in a third, pays three latencies per command.

7. Shrink the Suite

The fastest test is one you do not run in the browser. Apply the test pyramid honestly:

  • A form’s validation rules belong in unit tests of the component, not in 30 Selenium tests that type invalid values.
  • API behaviour belongs in API tests.
  • Selenium tests should cover user journeys and integration points that only a real browser exercises: navigation, rendering, JavaScript interaction, authentication flows.

Review the suite quarterly: tests that have never failed in a year and duplicate lower-level coverage are candidates for deletion. Tag a small smoke set to run on every commit and the full set nightly.

8. Reuse Browsers Carefully

Starting Chrome costs around a second; on a 500-test suite that is 8 minutes. Reusing one browser across tests saves it but risks state leakage. A middle ground: reuse the browser within a test class, clear cookies and storage between tests, and start fresh per class. Only do this after the suite is otherwise stable.

A Worked Example

A suite of 320 tests took 38 minutes in CI. The measurements and fixes:

ChangeTime saved
Login via cookies instead of UI (320 × 7 s)37 min of CPU time; 12 min wall at 3 parallels
Removed 41 sleep calls averaging 2 s1.4 min
pageLoadStrategy = eager3 min
Blocked analytics and chat widget2 min
Parallelism 3 → 8 with rebalanced shardsWall time divided by ~2.5
Deleted 60 tests duplicating unit coverage5 min

Result: 6 minutes wall time on every pull request.

Summary

  • Measure commands and wait time per test; fix the slowest 10% first.
  • Skip UI login and setup via cookies, APIs and seeds; that alone often halves the suite.
  • Remove sleeps, zero the implicit wait, use eager loading, poll faster.
  • Batch reads with one script; block third-party requests with BiDi.
  • Parallelise after the suite is stable, balance shards, and keep the browser layer thin.

Copy-paste recipes for this topic

Related lessons