Your suite is green. Every scenario passing, one after another, on a single emulator. Then it grows — 40 scenarios, then 120 — and a full run drags out to 35 minutes. So you reach for the obvious fix: flip on parallel execution and run several scenarios at once.

And everything falls apart.

Suddenly the failures make no sense. An element that was right there vanishes mid-tap. One test types its password into another test's screen. A driver that worked a second ago throws a NullPointerException; a second scenario dies with SessionNotCreatedException because two threads lunged for the same device. Worst of all, the failures move — a scenario goes red on one run, green the next, and never fails when you run it alone. The suite that was rock-solid sequentially is now unusable.

This isn't bad luck, and it isn't flaky tests. It's your framework telling you it was built single-threaded. A setup that runs one scenario at a time quietly leans on shared state — one driver, one device, one set of screen objects — and that shortcut stays invisible right up until two scenarios run side by side and start fighting over it.

This two-part finale removes those assumptions at the root. In this first part, you'll rebuild the framework's core so every scenario is fully isolated — its own driver, its own lifecycle, its own freshly-built screen objects — with no shared state left for two threads to collide over. In part 2, you'll scale that isolated core across real devices and finally flip parallel execution on, taking the same suite that ran in 35 minutes down to roughly half the time on two devices.

💡 This is Article 7 (Part 1) of the Framework Series. It builds directly on Configuration Management for Mobile Tests - Part 1 and Part 2. The typed DriverConfig, the profile-driven setup, and the test.retry-count value you defined but didn't wire up are all in place. This article makes the framework concurrent — the DriverManager from Article 3 gets its most significant refactor yet, but the screen objects and step definitions barely change.

What Parallel Execution Actually Means for Mobile

If you've parallelized web tests before, your instincts are about to mislead you. On the web, "run tests in parallel" mostly means spinning up more browser instances — cheap, near-instant, and bounded only by the CPU and memory on the box. You ask for more, you get more. Mobile breaks that assumption, and the way it breaks it shapes every decision in this article.

A mobile test needs a real device. Every parallel thread has to drive an actual emulator, simulator, or physical phone — and each one is a separate Appium session eating a real slice of CPU, RAM, and screen. You can't conjure a fourth device the instant a thread goes idle, the way a thread pool conjures a fourth worker. That one constraint reshapes the whole problem in two ways:

  • Your ceiling is devices, not CPU cores. On the web you might happily run 16 threads on an 8-core machine. On mobile, if you have 3 emulators booted, 3 is your hard limit — cores don't help. Ask for a 4th worker and it simply waits for a device to free up.
  • ⚠️ Every session has to be isolated end to end. Two Appium sessions aimed at the same device collide. Two threads sharing one driver object take turns sending it commands, so the taps and text from both scenarios get mixed together into one scrambled sequence. So isolation isn't an optional extra you add later — giving each scenario its own driver, device, and screen objects is the entire job this article walks you through.

So what is the thing that actually runs in parallel? In this framework, it's the Cucumber scenario — not the whole feature file at once, and not a single step on its own. The JUnit Platform engine treats each scenario as one self-contained test and hands it, start to finish, to any worker thread that's free.

So two scenarios running at once means two threads working in parallel. And because those scenarios share nothing, each thread needs its own of everything: its own driver, its own device, its own screen objects — independent from the first Given to the final assertion.

Why Your Current Framework Breaks Under Parallelism

Before fixing anything, it pays to see exactly why the current setup breaks — because it isn't a dozen small bugs. It's one design fact, easy to miss, that every symptom traces back to.

Cucumber and Spring share a single application context across the entire run. When you wire in cucumber-spring (set up back in Article 4 via @CucumberContextConfiguration), Cucumber builds one Spring ApplicationContext and reuses it for every scenario. Each scenario, on every thread, pulls its beans from that one shared container.

For a Spring app that's the whole point — beans are singletons by default precisely so they can be shared. But look at which beans you're sharing:

  • DriverManager is a @Component, so Spring makes it a singleton — one instance for the whole run. It builds exactly one AppiumDriver in its @PostConstruct method and hands that same object to every caller.
  • HomeScreen and LoginScreen are @Component singletons too — each built once, with its @AndroidFindBy/@iOSXCUITFindBy element proxies bound to that one driver at construction time.

Run sequentially, none of this matters: one driver, one device, one scenario at a time, sharing is free. Run two scenarios in parallel and that same sharing turns toxic — both threads reach into the one container and grab the one driver:

Thread A (scenario 1) ─┐
                       ├──▶  ONE DriverManager  ──▶  ONE AppiumDriver  ──▶  ONE device
Thread B (scenario 2) ─┘

Both threads call driverManager.getDriver() and get the same driver, pointed at the same device. So Thread A taps "Login" at the exact moment Thread B is mid-sendKeys, and the single Appium session has no idea the commands came from two different scenarios — it just runs them interleaved, and the result is incoherent. That's the root of every symptom from the intro: the vanishing elements, the password landing on the wrong screen, the failure you can never reproduce alone.

So the fix isn't a setting you toggle. It's a structural change in three parts:

  1. The driver must become one-per-thread instead of one-per-application.
  2. The driver's lifecycle must move from "once at startup" to "once per scenario."
  3. The screen objects must be rebuilt per scenario so their element proxies bind to the right driver.

Those three are the whole of this first part — they make each thread independent. (Independent threads still collide if they drive the same phone, so Part 2 adds a device pool and the port isolation that lets devices run side by side on one machine. But isolation comes first, because there's no point assigning devices to threads that still share a driver.) We'll take the three in order, one step at a time.

Step 1: Give Each Thread Its Own Driver

The core change is to stop storing the driver in a plain field and store it in a ThreadLocal instead. A ThreadLocal<AppiumDriver> holds a separate value for each thread that touches it: Thread A's get() returns Thread A's driver, Thread B's get() returns Thread B's. One DriverManager singleton, but a private driver behind it for every thread.

This also forces a second change. Today the driver is created in @PostConstruct — which runs once, when Spring builds the singleton, on whatever thread happens to start the context. That's exactly wrong for per-thread drivers: we need creation to happen on each scenario's own thread, so the new driver lands in that thread's slot. So we pull creation out of @PostConstruct into an explicit createDriver() method that a per-scenario hook will call (Step 2).

Here's the refactored DriverManager:

package com.mobileframework.driver;

import com.mobileframework.config.DriverConfig;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
import org.springframework.stereotype.Component;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;

@Component
public class DriverManager {

    private final DriverConfig config;
    private final ThreadLocal<AppiumDriver> driver = new ThreadLocal<>();

    public DriverManager(DriverConfig config) {
        this.config = config;
    }

    public void createDriver(DeviceSlot slot) {
        try {
            URL serverUrl = new URI(config.getAppiumUrl()).toURL();
            AppiumDriver created;

            if ("android".equalsIgnoreCase(config.getPlatform())) {
                UiAutomator2Options options = new UiAutomator2Options()
                        .setUdid(slot.getUdid())
                        .setSystemPort(slot.getPort())
                        .setApp(config.getAppPath())
                        .setAppPackage(config.getAppPackage())
                        .setAppActivity(config.getAppActivity());
                created = new AndroidDriver(serverUrl, options);

            } else if ("ios".equalsIgnoreCase(config.getPlatform())) {
                XCUITestOptions options = new XCUITestOptions()
                        .setUdid(slot.getUdid())
                        .setWdaLocalPort(slot.getPort())
                        .setApp(config.getAppPath());
                created = new IOSDriver(serverUrl, options);

            } else {
                throw new IllegalArgumentException(
                        "Unknown platform: '" + config.getPlatform()
                                + "'. Set driver.platform to 'android' or 'ios'.");
            }

            driver.set(created);

        } catch (URISyntaxException | MalformedURLException e) {
            throw new IllegalStateException("Invalid Appium server URL: " + config.getAppiumUrl(), e);
        }
    }

    public AppiumDriver getDriver() {
        AppiumDriver current = driver.get();
        if (current == null) {
            throw new IllegalStateException(
                    "No driver on this thread. Did the @Before hook call createDriver()?");
        }
        return current;
    }

    public void quitDriver() {
        AppiumDriver current = driver.get();
        if (current != null) {
            current.quit();
            driver.remove();
        }
    }
}

Four things changed from the Article 6 version. Take them one at a time:

  • ThreadLocal<AppiumDriver> replaces the plain field. Each thread that calls getDriver() sees only its own driver — there's no shared AppiumDriver left for two threads to fight over.
  • createDriver(DeviceSlot slot) replaces @PostConstruct. Creation is now an explicit call made on the scenario's own thread, so driver.set(...) lands in that thread's slot. The Appium options logic is untouched — same platform branch, same builders as before.
  • getDriver() now throws a clear error when no driver exists, instead of handing back null. An empty ThreadLocal returns null, and a null driver doesn't fail where you asked for it — it fails later, as a NullPointerException ten frames deep inside a screen object. Checking for null up front and throwing a message that names the cause ("Did the @Before hook call createDriver()?") turns a baffling stack trace into a one-line fix.
  • quitDriver() calls driver.remove(), not just quit(). This is the line people forget. JUnit Platform reuses worker threads across many scenarios, so if you quit() without remove(), the dead driver lingers in the thread's ThreadLocal — and the next scenario unlucky enough to land on that thread inherits a closed session.
🚨 Always remove() after quit(). A ThreadLocal you set but never clear is a classic thread-pool memory leak — and worse here, it leaves a closed driver in place for the next scenario to trip over. Quit the session, then wipe the slot.
💡 This is the Single Responsibility Principle from the framework blueprint, sharpened. DriverManager now does exactly one job: hand the current thread the driver it owns and manage that driver's life. It doesn't decide when to create one (the hook does) or which device to use (the pool does) — it just owns the per-thread driver.

One detail to flag: createDriver takes a DeviceSlot and reads slot.getUdid() and slot.getPort() rather than pulling the UDID straight from config. That's deliberate. A single config value can't feed parallel threads — each one needs a different device. You'll build DeviceSlot and the pool that hands them out in Part 2; for now, read it as "the device this thread was assigned."

Step 2: Move the Driver Lifecycle into Per-Scenario Hooks

With creation no longer happening at startup, something has to call createDriver() at the start of each scenario and quitDriver() at the end — both on the scenario's own thread. That's exactly what Cucumber's @Before and @After hooks are for. They run on the same thread that runs the scenario's steps, which is what makes the ThreadLocal line up correctly.

Create a dedicated hooks class in your step-definitions package. It depends on a DevicePool — the device-leasing component you'll build in Part 2; for now, read acquire() as "claim a free device" and release() as "give it back":

package com.mobileframework.steps;

import com.mobileframework.driver.DeviceSlot;
import com.mobileframework.driver.DevicePool;
import com.mobileframework.driver.DriverManager;
import io.cucumber.java.After;
import io.cucumber.java.Before;

public class ScenarioHooks {

    private final DevicePool devicePool;
    private final DriverManager driverManager;
    private DeviceSlot slot;

    public ScenarioHooks(DevicePool devicePool, DriverManager driverManager) {
        this.devicePool = devicePool;
        this.driverManager = driverManager;
    }

    @Before
    public void startSession() {
        slot = devicePool.acquire();        // claim a free device
        driverManager.createDriver(slot);   // build this thread's driver
    }

    @After
    public void endSession() {
        driverManager.quitDriver();         // quit + clear the ThreadLocal
        if (slot != null) {
            devicePool.release(slot);       // hand the device back to the pool
        }
    }
}

Two things make this work correctly under parallelism, and both are easy to get wrong:

  • Every scenario gets its own ScenarioHooks object. Cucumber builds a fresh instance of each glue class for each scenario — and because ScenarioHooks is a plain step-definitions class (not a Spring @Component), cucumber-spring follows that rule and creates a new one per scenario, dependencies injected. So the slot field is never shared: Thread A and Thread B each have their own ScenarioHooks, holding their own slot. There's no single field for two scenarios to overwrite.
  • @Before runs first, on the scenario's own thread. It fires before any step in that scenario, on the same thread the steps will run on. So this thread's driver is already sitting in the ThreadLocal by the time the first step reaches for a screen object.
⚠️ Keep your hooks in their own class — never mix @Before that creates the driver with steps that use screen objects in the same class. Cucumber instantiates a glue class (and eagerly wires its dependencies) the first time it needs any method on it. If your driver-creating @Before lived in the same class as steps that inject LoginScreen, Cucumber would try to build that LoginScreen — which reads the driver in its constructor — before the hook had a chance to create it. A separate ScenarioHooks class that depends only on DevicePool and DriverManager sidesteps the ordering trap entirely.
💡 Why hooks and not Spring's @PreDestroy? @PreDestroy fires when the Spring context shuts down — once, at the very end of the whole run. You need teardown per scenario, on the scenario's thread. @After is the only thing that fires at the right time on the right thread.

Step 3: Make Screen Objects Scenario-Scoped

A per-thread driver isn't enough on its own. Your screen objects capture a driver too — and if they capture the wrong one, all the work from Step 1 is wasted. To see how, look again at BasePage from Article 3, the class every screen object extends and the place where each one grabs its driver. It needs one small change first to fit the new per-scenario timing:

public abstract class BasePage {

    protected final AppiumDriver driver;

    protected BasePage(DriverManager driverManager) {
        this.driver = driverManager.getDriver();
        PageFactory.initElements(new AppiumFieldDecorator(driver, Duration.ofSeconds(10)), this);
    }
}

The only change is the explicit Duration.ofSeconds(10) passed to AppiumFieldDecorator, raising the default timeout of 1 second. The number to be clear about here is what it actually measures: it's a ceiling, not a fixed delay. The element proxies poll for their element and return the instant it's present — so a screen that's already rendered costs you nothing close to ten seconds. The timeout is spent only in the worst case: an element that's slow to appear or missing entirely, where the lookup keeps retrying up to ten seconds before giving up and failing.

Why raise it at all? The old 1-second default was fine when the driver was created during Spring startup, with the app settled long before any step ran. Now the driver is created in @Before, right before the first step executes — so the very first lookup can land on an app that's still painting its UI. One second is too tight for that. Ten is a safe ceiling for mobile: long enough to ride out a freshly launched app, a slow emulator, or a parallel run under load, but — because it returns early the moment the element shows up — adding almost nothing to a healthy suite.

The constructor captures the driver and binds the @AndroidFindBy/@iOSXCUITFindBy element proxies to it at construction time. Whatever driver exists when the screen object is built is the driver those elements will use forever after.

Now remember HomeScreen and LoginScreen are @Component singletons — built once, the first time they're needed, and reused for the entire run. So they capture one driver, from one thread, and every scenario afterward reuses those same proxies pointed at that first thread's device. Even with a perfect per-thread driver, a singleton screen object quietly undoes all of it.

The fix is to tell Spring to build a fresh screen object per scenario, on the scenario's own thread — so each one captures the right driver. cucumber-spring provides exactly this with the @ScenarioScope annotation:

package com.mobileframework.screens;

import com.mobileframework.driver.DriverManager;
import com.mobileframework.pages.BasePage;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import io.cucumber.spring.ScenarioScope;
import org.openqa.selenium.WebElement;
import org.springframework.stereotype.Component;

@Component
@ScenarioScope
public class LoginScreen extends BasePage {

    @AndroidFindBy(accessibility = "input-email")
    @iOSXCUITFindBy(accessibility = "input-email")
    private WebElement emailField;

    // ... remaining fields unchanged ...

    public LoginScreen(DriverManager driverManager) {
        super(driverManager);
    }

    // ... methods unchanged ...
}

Add the same @ScenarioScope to HomeScreen and every other screen object. That one annotation is the entire change — the fields, constructor, and methods stay exactly as they were.

Here's why the timing works out. @ScenarioScope beans are created lazily, the first time a scenario actually uses them — which is during a step, after the @Before hook has already created this thread's driver. So when BasePage's constructor calls driverManager.getDriver(), it gets a live driver, and PageFactory.initElements binds the proxies to the correct device. At scenario end, the scoped instance is discarded; the next scenario builds its own.

💡 @ScenarioScope is what makes test isolation real. A new screen object per scenario means no element proxy, no cached state, and no driver reference ever leaks from one scenario into the next. Combined with the per-thread driver, each scenario is now genuinely independent — which is the precondition for running them in any order, on any thread, at the same time.
💡 Notice how little moved. BasePage is untouched. The screen-object fields and methods are untouched. The step definitions are untouched. The entire concurrency model changed and the test-facing code barely noticed — that's the payoff of having kept driver access behind DriverManager since Article 3.
⚠️ Screen objects must live in src/test/java, not src/main/java. @ScenarioScope comes from cucumber-spring, which is a test-scoped dependency. If LoginScreen or HomeScreen sit under src/main/java, your IDE will refuse to resolve the import — IntelliJ surfaces this as "Move LoginScreen to test root." The fix is to move all screen objects to src/test/java in the team-tests module. They belong there anyway: screen objects are test infrastructure used only by step definitions and hooks, never by production code. BasePage stays in core/src/main/java — it carries no Cucumber dependency and is genuine shared framework code.

What's Next?

Your framework is now thread-safe at its core. Every scenario gets its own driver (a ThreadLocal in DriverManager), its own lifecycle (@Before/@After in ScenarioHooks), and its own freshly-built screen objects (@ScenarioScope). There's no shared state left for two threads to fight over — the toxic diagram from the start of this article can't happen anymore.

But notice what you haven't done yet: actually run anything in parallel. Right now every scenario still leases the same device, and the parallel switch is still off. That's deliberate — isolation had to come first, because assigning devices to threads that still shared a driver would have solved nothing.

Part 2: Scale Across Devices and Turn It On finishes the job. You'll build the device pool that hands each thread its own physical device, isolate the ports so multiple sessions coexist on one machine, flip parallel execution on in three lines, and add a retry safety net for the genuinely rare blip. By the end, two emulators light up and drive at once — and the 35-minute suite finishes in roughly half the time. Add more devices and it drops further, close to linearly.