Selenium Grid
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Selenium Grid runs WebDriver sessions on browsers that live somewhere else: on another machine, in a container, or in a
fleet of containers that scale with the load. The test code stays the same except for one line: instead of starting a
local ChromeDriver, it creates a RemoteWebDriver that points at the Grid, and the Grid decides which Node has a free
slot for the requested browser. This page is written for Selenium 4.51.0 (released 9 October 2026, the latest release on
25 October 2026) and the selenium/* Docker images at the same version, and it was checked on 25 October 2026. The
examples were run against the Bookshelf application with
Java 17 and JUnit 5.14.4, and with Python 3.13 and pytest 9.1, against real Grids: the Selenium Server jar on the host
(Standalone) and Docker containers started with Docker Compose (Standalone, Hub and Nodes, fully distributed, Dynamic
Grid, and a video recorder). The Helm chart with KEDA, an external Redis or PostgreSQL session map, tracing with Jaeger
and the custom Node were not run, for the reasons given in their sections.
How to create a driver, the options of each browser and Selenium Manager are explained in Drivers, Browsers & Options; the first test is in Getting Started. The general question of covering several browsers is discussed in Cross-Browser Testing, and wiring a Grid into a pipeline in Running in CI/CD. Playwright can also borrow a browser from a Grid; that is covered in Playwright Advanced Topics.
When to use a Grid
A Grid pays off when the number of browser sessions you want at the same time is larger than one machine can serve, or when the machine that runs the tests is not the machine that can run the browser. The Selenium documentation lists the cases: run tests in parallel against different browser types, browser versions and operating systems, and shorten the time a suite needs. The arithmetic is simple:
Number of tests * average test time / number of Nodes = total execution time
15 * 45 s / 1 = 11 min 15 s (no Grid)
15 * 45 s / 5 = 2 min 15 s (Grid with 5 Nodes)
15 * 45 s / 15 = 45 s (Grid with 15 Nodes)
100 * 120 s / 15 = 13 min 20 s (over 3 hours without a Grid)
Source: Selenium — When to Use Grid
The formula assumes the tests are independent (see Test Design & Page Objects) and that the Grid has as many free slots as there are tests; the queue in front of the Distributor (explained below) absorbs the difference when it does not. A Grid is not always the answer:
-
One machine with enough CPU and memory can already run several local drivers in parallel. Start there: a Grid adds a network hop to every command, one more thing to operate and a security surface.
-
A Grid does not make a test faster, it makes more tests run at the same time. A slow test stays slow.
-
Browsers that exist only on specific hardware (Safari on macOS, the legacy Internet Explorer mode of Edge on Windows) need a Node on that hardware; a Linux container cannot provide them.
-
If you do not want to operate infrastructure, a cloud grid (BrowserStack, Sauce Labs, TestMu AI and similar) speaks the same WebDriver protocol, so the
RemoteWebDrivercode on this page works against them with a different URL and vendor capabilities. See Cross-Browser Testing for those services.
The three roles
Grid 4 is composed of six components (listed in the next section) that can be started together or separately. Selenium
Server, a single jar (selenium-server-4.51.0.jar, attached to every GitHub release), exposes them as sub-commands
(standalone, hub, node, distributor, router, sessions, sessionqueue, event-bus). You choose how many of
them to run in each process:
| Role | What runs in the process | Use it for |
|---|---|---|
Standalone |
All the components and a Node in a single process, on a single machine. Listens on |
Developing or debugging with |
Hub and Node |
The Hub runs the Router, Distributor, Session Map, New Session Queue and Event Bus. Each Node is a separate process, usually on a separate machine, that registers with the Hub. |
The most common setup: machines with different operating systems and browser versions behind one URL, capacity that can be added or removed without stopping the Grid. |
Distributed |
Every component in its own process, ideally on its own machine. |
Large Grids (the documentation puts "large" at 60 to 100 Nodes with a Hub, more than 100 with the distributed role), or when a component must be scaled or restarted on its own, for example with an external session map. |
Standalone
Standalone needs Java 11 or newer, a browser, and either its driver on the PATH or the --selenium-manager true flag so
the Node asks Selenium Manager to download it:
java -jar selenium-server-4.51.0.jar standalone
The tests in this page ran against a Standalone started from this TOML file (the complete file is shown in the configuration section) on port 4605, with Selenium Manager supplying the drivers. The server detected the browsers installed on the Mac and created two slots for each Chromium-based browser and Firefox, and one for Safari:
21:48:38.902 INFO [NodeOptions.getSessionFactories] - Detected 10 available processors
21:48:58.646 WARN [SeleniumManager.lambda$runCommand$0] - Driver unavailable: /Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver
21:48:58.669 INFO [NodeOptions.report] - Adding Chrome for {"browserName": "chrome","platformName": "mac"} 2 times
21:48:58.671 INFO [NodeOptions.report] - Adding Edge for {"browserName": "MicrosoftEdge","platformName": "mac"} 2 times
21:48:58.671 INFO [NodeOptions.report] - Adding Firefox for {"browserName": "firefox","platformName": "mac"} 2 times
21:48:58.672 INFO [NodeOptions.report] - Adding Safari for {"browserName": "safari","platformName": "mac"} 1 times
21:48:58.940 INFO [Standalone.execute] - Started Selenium Standalone 4.51.0 (revision 35c5fab ...): http://192.168.86.40:4605
Two things to notice. First, the Node advertises slots for browsers whose driver it merely found (here Edge, which is not
installed, and Firefox, which does not launch on this host), so a request for them is accepted and fails later: a slot is
a promise about a driver, not about a working browser. Restrict the list with --driver-implementation or the TOML key
drivers (see below). Second, the slot count (totalSlots was 7 in GraphQL) is larger than maxSession (2): slots say
what could start, maxSession caps what does run at the same time.
Hub and Node
Start the Hub, then one or more Nodes. On the same machine the defaults are enough:
java -jar selenium-server-4.51.0.jar hub
java -jar selenium-server-4.51.0.jar node
java -jar selenium-server-4.51.0.jar node --port 5555 # a second Node would use another port, such as 6666
On different machines a Node registers by publishing a message to the Hub’s Event Bus and then letting the Hub call it back
over HTTP, so the Hub machine must expose the Event Bus ports (4442 and 4443 by default) and the Node must expose its own
port. If the Hub uses default ports, the --hub flag is the short form; otherwise name the Event Bus ports explicitly:
java -jar selenium-server-4.51.0.jar node --hub http://<hub-ip>:4444
# a Hub with non-default ports ...
java -jar selenium-server-4.51.0.jar hub --publish-events tcp://<hub-ip>:8886 --subscribe-events tcp://<hub-ip>:8887 --port 8888
# ... and the Node that registers with it
java -jar selenium-server-4.51.0.jar node --publish-events tcp://<hub-ip>:8886 --subscribe-events tcp://<hub-ip>:8887
Fully distributed
In the distributed role the start order follows the dependencies between the components: Event Bus and Session Map first, then the New Session Queue, the Distributor, the Router and finally the Nodes. The default ports are 4442, 4443 and 5557 for the Event Bus, 5559 for the queue, 5556 for the Session Map, 5553 for the Distributor, 4444 for the Router and 5555 for a Node.
java -jar selenium-server-4.51.0.jar event-bus --publish-events tcp://<event-bus-ip>:4442 --subscribe-events tcp://<event-bus-ip>:4443 --port 5557
java -jar selenium-server-4.51.0.jar sessionqueue --port 5559
java -jar selenium-server-4.51.0.jar sessions --publish-events tcp://<event-bus-ip>:4442 --subscribe-events tcp://<event-bus-ip>:4443 --port 5556
java -jar selenium-server-4.51.0.jar distributor --publish-events tcp://<event-bus-ip>:4442 --subscribe-events tcp://<event-bus-ip>:4443 \
--sessions http://<sessions-ip>:5556 --sessionqueue http://<new-session-queue-ip>:5559 --port 5553 --bind-bus false
java -jar selenium-server-4.51.0.jar router --sessions http://<sessions-ip>:5556 --distributor http://<distributor-ip>:5553 \
--sessionqueue http://<new-session-queue-ip>:5559 --port 4444
java -jar selenium-server-4.51.0.jar node --publish-events tcp://<event-bus-ip>:4442 --subscribe-events tcp://<event-bus-ip>:4443
--bind-bus false on the Distributor means "connect to the Event Bus, do not start one": in a Hub the Event Bus is bound by
the Hub itself. These commands were not run on the host as separate processes (they would need five local ports for each
test run); the same topology, with the same components, was run with the official Docker images and is shown in the
docker-selenium section.
Components and architecture
The figure shows who talks to whom when a test creates a session and when it sends later commands.
| Component | Responsibility |
|---|---|
Router |
The front door of the Grid, and the only component that may be reachable from outside (the documentation strongly advises against exposing even that to the wider internet). A new session request is forwarded to the New Session Queue; a request for an existing session is sent to the Node found in the Session Map. It forwards each request to the component best able to handle it, so the other components are not loaded for nothing. |
New Session Queue |
Holds new session requests in first-in, first-out order. It has a request timeout ( |
Distributor |
Does two jobs. It registers Nodes and tracks their capabilities in the Grid Model, and it polls the New Session Queue, finds a matching free slot on a Node, creates the session there and stores the session id and Node in the Session Map. If every matching slot is busy it returns the request to the queue. |
Session Map |
A data store from session id to the Node that runs the session. The Router asks it for the owner of every command to an existing session. In-memory by default; Redis and JDBC (SQL) implementations exist (see the external datastore section). |
Node |
Owns a machine’s slots. A Node only executes the commands it receives; it does not decide anything else. By default it registers
every driver it finds on the |
Event Bus |
The message channel between Nodes, Distributor, New Session Queue and Session Map. Most internal communication uses events instead of HTTP calls. In the distributed role it is the first component to start. The default implementation is ZeroMQ. |
Two concepts from the architecture page appear in every configuration: a slot is the place where one session can run, and
every slot has a stereotype, the minimal set of capabilities a new session request must match before the Distributor
sends it to that slot (for example {"browserName": "firefox"}). The Grid Model is the Distributor’s own picture of the
Grid; it is used instead of asking every Node, which is fast but can briefly lag reality, for example just after the
Distributor starts.
Synchronous and asynchronous calls
The Grid uses two mechanisms. Synchronous JSON-over-HTTP calls are used when the action is synchronous (most WebDriver commands) or when missing the answer would be a problem. Asynchronous events on the Event Bus are used to broadcast information to anyone who is interested or when losing a message does not matter. Events are more decoupled from their listeners than HTTP calls are.
Node registration and the status object
A Node emits a heartbeat event periodically (60 seconds by default, --heartbeat-period). When the Distributor sees one it
calls the Node’s /status endpoint over HTTP and, from that, builds the Grid Model. It uses the same endpoint to check the
Node on a schedule (--healthcheck-interval, 120 seconds by default), and the Node keeps sending heartbeats so a Distributor
that was restarted catches up. Nodes the Distributor has not heard from for a while are purged (--purge-nodes-interval,
30 seconds). The Node status is a JSON object with these fields:
| Field | Type | Meaning |
|---|---|---|
|
string |
|
|
string |
The URI the other components use to reach the Node. |
|
integer |
Epoch time of the last session started here. When everything else is equal the Distributor prefers the Node idle the longest. |
|
integer |
How many sessions may run at once before the Node counts as full. |
|
string |
A UUID for this instance of the Node. |
|
object |
|
|
array |
The slot objects: |
|
string |
The Node’s version, which matches the Selenium version. |
Source: Selenium — Grid architecture
A Node can have more slots than it can run at once: a Node with maxSessionCount 10 and three browsers has 10 slots for each
of them, and the cap applies to the total.
Slot selection
The Distributor chooses among the Nodes that match with a slot selector. The default, DefaultSlotSelector, picks the Node
that has been free the longest, which spreads sessions evenly. GreedySlotSelector does the opposite and fills one Node
before using another, which suits autoscaled environments where idle Nodes should disappear. Select it with --slot-selector
(the environment variable SE_DISTRIBUTOR_SLOT_SELECTOR in the Docker images). --slot-matcher replaces the code that decides
whether a Node can serve a request, and --reject-unsupported-caps true makes the Distributor fail a request at once
if no Node could ever serve it (useful when Nodes are not created on demand, and the behaviour seen in this page’s Chrome-only Standalone).
Grid sizes and sizing a Node
Creating sessions concurrently depends on the CPUs of the machine that runs the Distributor: with 4 CPUs it can create up to
4 sessions at the same time (--newsession-threadpool-size, which defaults to the number of processors times three, tunes it).
A Node’s default --max-sessions is the number of CPUs, with the exception of Safari, which is always one; the documentation
suggests budgeting about one CPU and 1 GB of memory for each browser session and measuring from there. --override-max-sessions true
allows a higher value than the CPU count, at the risk of an unstable Node. Prefer many small Nodes over one big one: a failing Node then
affects fewer sessions, and containers make that cheap.
| Size | Setup (rough guidance from the documentation) |
|---|---|
Small |
Standalone, or Hub and Node with 5 or fewer Nodes. |
Middle |
Hub and Node with between 6 and 60 Nodes. |
Large |
Hub and Node with between 60 and 100 Nodes, or the distributed role with more than 100 Nodes. |
Securing a Grid
A Grid executes browsers on behalf of whoever can reach it, so an exposed one lets third parties browse your internal applications, read files the browser can reach and run custom binaries. The official page says to protect it with firewall permissions. The built-in options are:
-
Do not publish the Grid port to the internet. Put it on a private network or behind a reverse proxy, and use
--sub-pathif the proxy serves it under a path. -
Router basic authentication (
usernameandpasswordin the[router]TOML section, or--usernameand--password). The Grid UI and the new session request then need the credentials. In Java, pass them in the client configuration; in other bindings put them in the URL. -
A shared registration secret (
--registration-secret) so only Nodes that know it can register or be drained or removed through the management endpoints. -
HTTPS with
--https-certificateand--https-private-key;java -jar selenium-server-4.51.0.jar info securityprints the details.
[router]
username = "admin"
password = "myStrongPassword"
ClientConfig clientConfig = ClientConfig.defaultConfig()
.baseUrl(new URL("http://localhost:4444"))
.authenticateAs(new UsernameAndPassword("admin", "myStrongPassword"));
HttpCommandExecutor executor = new HttpCommandExecutor(clientConfig);
RemoteWebDriver driver = new RemoteWebDriver(executor, new ChromeOptions());
Real credentials belong in the environment or a secret store, not in a committed file. This example was not run: the Grids used for the other examples had no authentication.
Configuration
Every component takes the same three kinds of input, which are merged: command-line flags, one or more TOML (or JSON) files
passed with --config, and, in the Docker images, environment variables that the start-up scripts translate into flags (for
example SE_NODE_MAX_SESSIONS for --max-sessions). The Selenium team recommends TOML files because they can be read and
version-controlled, and flags for the few values that change per environment. The documentation warns that its option
tables can lag the code, so when in doubt ask the jar itself:
java -jar selenium-server-4.51.0.jar info config # how configuration works
java -jar selenium-server-4.51.0.jar info security # HTTPS, registration secret
java -jar selenium-server-4.51.0.jar info sessionmap # external session map set-up
java -jar selenium-server-4.51.0.jar info tracing # OpenTelemetry and Jaeger
java -jar selenium-server-4.51.0.jar --config-help # every command and its options
java -jar selenium-server-4.51.0.jar standalone --help # options of one component
Source: Selenium — Configuration help
CLI options by section
Only the options that matter in everyday use are tabulated here; the reference lists many more per section (Distributor,
Docker, Kubernetes, Events, Logging, Network, Node, Router, Relay, Server, SessionQueue, Sessions). In a TOML file each
option is a key in the section named after its group, without the -- prefix and with the same dashes.
| Option | Default | Meaning |
|---|---|---|
|
4444 (Router, Hub, Standalone), 5555 (Node) |
Port to listen on. |
|
automatic, true, none |
Name the server reports, whether to bind to it, and the URL other components should use. Needed when the server cannot see its own reachable address, for example in a container behind NAT. |
|
none |
A path prefix for all user-facing routes on the Hub, Router and Standalone. |
|
number of CPUs |
Maximum concurrent sessions on a Node. |
|
300 seconds |
A Node kills a session with no activity for this long, releasing the slot. Raise it if the test legitimately pauses; lower it so crashed tests do not hold slots. |
|
true, all, false |
Which drivers a Node registers, and whether it uses Selenium Manager to obtain missing ones. |
|
none |
Custom browser stereotypes with |
|
0 |
Drain and stop the Node after this many sessions. Useful in Kubernetes where each Node should serve a number of tests and be replaced. |
|
true, true |
Proxy the Chrome DevTools Protocol and WebDriver BiDi web sockets through the Grid. Disable them if the network cannot carry web sockets. |
|
false |
Let the Node keep every file a session downloads in a temporary directory so the client can fetch it. |
|
10 |
Maximum web socket connections for one session. |
|
false |
Let users delete a session from the Grid UI. |
|
7900, |
Where the Node tells clients to find its noVNC stream and which environment variables decide whether a VNC stream exists. |
|
120 s, 10 s, false |
How long and how often a starting Node tries to register; whether it exits if it cannot (so a container orchestrator restarts it). |
|
0 (off) |
Mark a Node as down after this many consecutive session creation failures. |
|
300 s, 10 s, 15 ms, 20 |
Queue behaviour: how long a request may wait, how often that is checked, how soon a request that found only busy slots is retried and how many requests are taken per batch. |
|
120 s, 30 s |
How often the Distributor pings Nodes and how often it purges those that have been down for a while. |
|
processors * 3 |
Threads the Distributor uses to create sessions. |
|
none |
Shared secret for Node registration and the management endpoints. |
|
none, none, false |
Basic authentication for the Router, and switching off the Grid UI. |
|
INFO, console, false, true, false, true |
Logging and tracing (see the observability section). |
|
none |
Global option before the command: jars or directories added to the class path, for custom implementations such as a Redis session map or a custom Node. |
|
none, |
The Node’s Docker mode: image to stereotype mapping, the daemon, the recorder image and the host configuration passed to session containers. |
|
none |
The same idea for Kubernetes: the |
|
none |
The Node’s relay mode (see below). |
TOML
The general shape is sections with keys. The Standalone used in this page’s tests was started with this file:
[server]
port = 4605
[node]
max-sessions = 2
selenium-manager = true
[sessionqueue]
session-request-timeout = 120
java -jar selenium-server-4.51.0.jar standalone --config standalone.toml
The server logged Started Selenium Standalone 4.51.0 ... http://192.168.86.40:4605 and /status answered on that port
with "ready": true; a RemoteWebDriver session for headless Chrome created through it loaded Bookshelf’s
/books page (the title was "Catalogue - Bookshelf") in 0.9 seconds. The command-line equivalent is
standalone --port 4605 --max-sessions 2 --selenium-manager true --session-request-timeout 120. A flag given on the
command line wins over the file.
More examples from the documentation, each of which can be combined with the others in one file:
# Only Chrome and Firefox, at most 3 concurrent sessions
[node]
drivers = ["chrome", "firefox"]
max-sessions = 3
# Custom browser binaries and versions (Firefox Nightly, Chrome Beta, Chrome Dev)
[node]
detect-drivers = false
[[node.driver-configuration]]
max-sessions = 100
display-name = "Firefox Nightly"
stereotype = "{\"browserName\": \"firefox\", \"browserVersion\": \"93\", \"platformName\": \"MAC\", \"moz:firefoxOptions\": {\"binary\": \"/Applications/Firefox Nightly.app/Contents/MacOS/firefox-bin\"}}"
[[node.driver-configuration]]
display-name = "Chrome Beta"
stereotype = "{\"browserName\": \"chrome\", \"browserVersion\": \"94\", \"platformName\": \"MAC\", \"goog:chromeOptions\": {\"binary\": \"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta\"}}"
webdriver-executable = '/path/to/chromedriver/94/chromedriver'
# A Node whose sessions each run in a Docker container (Dynamic Grid); the daemon must be reachable over HTTP/TCP
[node]
detect-drivers = false
max-sessions = 2
[docker]
configs = [
"selenium/standalone-chrome:4.51.0", "{\"browserName\": \"chrome\"}",
"selenium/standalone-firefox:4.51.0", "{\"browserName\": \"firefox\"}"
]
url = "http://localhost:2375"
video-image = "selenium/video:ffmpeg-8.1-20261010"
# Relay: forward matching sessions to another WebDriver service, such as an Appium server or a cloud provider
[node]
detect-drivers = false
[relay]
url = "http://localhost:4723/wd/hub"
status-endpoint = "/status"
protocol-version = "HTTP/1.1"
# The number is the number of slots; the JSON is the stereotype
configs = [
"5", "{\"browserName\": \"chrome\", \"platformName\": \"android\", \"appium:platformVersion\": \"11\"}"
]
# Custom capabilities to route a request to specific Nodes. Every Node sets them and every request must send them.
[node]
detect-drivers = false
[[node.driver-configuration]]
display-name = "firefox"
stereotype = '{"browserName": "firefox", "platformName": "macOS", "browserVersion":"96", "networkname:applicationName":"node_1", "nodename:applicationName":"app_1" }'
max-sessions = 5
# Keep the files a session downloads on the Node
[node]
enable-managed-downloads = true
These snippets were not run, with the exception of the idea behind the Docker one, which the Dynamic Grid example below uses in the official container instead of a host Node. The matching Java request for the custom capabilities example is:
FirefoxOptions options = new FirefoxOptions();
options.setCapability("networkname:applicationName", "node_1");
options.setCapability("nodename:applicationName", "app_1");
options.setBrowserVersion("96");
options.setPlatformName("macOS");
WebDriver driver = new RemoteWebDriver(new URL("http://localhost:4444"), options);
Custom capability names need a vendor prefix (name:key) and must be present in the configuration of every Node and in every
request. A Selenium Server started with different flags for a distributed Grid still takes --config for each component, so a
shared file with all sections works for every process.
The HTTP client
Grid uses AsyncHttpClient (built on Netty) by default, which has not been actively maintained since June 2021. Since Selenium 4.5
the JDK client (the HTTP and web socket client built into Java 11 and newer) can be used instead: download
selenium-http-jdk-client from Maven Central, put it on the class path with --ext, and set the system property. It has to be
done on every component of a Hub and Node or distributed Grid.
java -Dwebdriver.http.factory=jdk-http-client -jar selenium-server-4.51.0.jar --ext selenium-http-jdk-client-4.51.0.jar standalone
Endpoints
All of these are plain HTTP, so curl is enough. The Grid URL is the Standalone address, the Hub address or the Router address,
depending on the role. The status, session deletion, GraphQL and queue requests and the Node drain were run against the Docker Standalone (port 4601) and the
distributed Grid (4603) of this page; the others are shown as documented.
| Endpoint | Purpose | Notes |
|---|---|---|
|
Grid state: |
|
|
Terminates the session, quits the driver and removes it from the active sessions map. |
Later requests with that id fail. |
|
The GraphQL API (next section). |
The Grid UI uses it. |
|
The Grid UI: Nodes, sessions with their capabilities, the queue, and a link to the noVNC view of each session. |
|
|
The requests in the New Session Queue and their total. |
On the Router, Hub or Standalone. |
|
Clears the queue: every queued request gets an error, the result is the number removed. |
Needs the |
|
Removes a Node from the Grid. Does not stop its sessions or the Node process. |
Needs |
|
Graceful shutdown: no new sessions, the Node stops after its running sessions finish. |
Needs |
|
The Node’s own health check and slots. |
Hub and Node, and distributed. |
|
Drain the Node directly, ask if it owns a session ( |
Need |
Source: Selenium — Grid endpoints
If the Grid was started without a registration secret, send the header with no value (--header 'X-REGISTRATION-SECRET;' in
curl). This is what the official page documents; it worked on the distributed Grid of this page:
$ curl -s localhost:4601/status | python3 -c 'import sys,json;v=json.load(sys.stdin)["value"];print(v["ready"],v["message"])'
True Selenium Grid ready.
$ curl -s localhost:4603/status | python3 -c 'import sys,json;n=json.load(sys.stdin)["value"]["nodes"][0];print(n["id"],n["availability"])'
10f05347-d4ce-40be-8786-020e915cec77 UP
$ curl -s -i -X POST "localhost:4603/se/grid/distributor/node/10f05347-d4ce-40be-8786-020e915cec77/drain" -H 'X-REGISTRATION-SECRET;'
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
{
"value": true,
"message": "Node status was successfully set to draining."
}
$ curl -s localhost:4603/status # the idle Node left; the Grid reports ready false and no Nodes
Source: Selenium — Grid endpoints
A session that exists can also be deleted, which the Standalone answered with {"value":null} and which freed its slot:
$ curl -s -X POST localhost:4601/session -H 'content-type: application/json' \
-d '{"capabilities":{"alwaysMatch":{"browserName":"chrome","se:name":"curl demo"}}}'
# ... {"value":{"sessionId":"287152c704955abb1e3cef071df993fe", ...
$ curl -s localhost:4601/status | python3 -c '...' # slots: ['287152c7...', None]
$ curl -s -X DELETE localhost:4601/session/287152c704955abb1e3cef071df993fe
{"value":null}
$ curl -s localhost:4601/status | python3 -c '...' # slots: [None, None]
Source: Selenium — Grid endpoints
GraphQL
POST /graphql takes a JSON body with a query and returns exactly the fields requested. The schema (as documented) is:
session(id) { id, capabilities, startTime, uri, nodeId, nodeUri, sessionDurationMillis, slot { id, stereotype, lastStarted } }
grid { uri, totalSlots, nodeCount, maxSession, sessionCount, version, sessionQueueSize }
sessionsInfo { sessionQueueRequests, sessions [ same fields as session ] }
nodesInfo { nodes [ { id, uri, status, maxSession, slotCount, sessions [...], sessionCount, stereotypes, version,
osInfo { arch, name, version } } ] }
Source: Selenium — GraphQL query support
status is an enum (UP, DRAINING or UNAVAILABLE); scalars are Int, Float, String, Boolean and ID, and a
query must descend until it reaches scalars. These requests were run against the Docker Standalone:
$ curl -s -X POST -H "Content-Type: application/json" \
--data '{"query": "{ grid { uri, totalSlots, nodeCount, maxSession, sessionCount, version, sessionQueueSize } }"}' \
localhost:4601/graphql
{"data":{"grid":{"uri":"http://172.18.0.2:4444","totalSlots":2,"nodeCount":1,"maxSession":2,"sessionCount":0,
"version":"4.51.0 (revision 35c5fab ...)","sessionQueueSize":0}}}
$ curl -s -X POST -H "Content-Type: application/json" \
--data '{"query": "{ nodesInfo { nodes { id, status, slotCount, sessionCount, osInfo { name, arch, version } } } }"}' \
localhost:4601/graphql
{"data":{"nodesInfo":{"nodes":[{"id":"95b5285f-...","status":"UP","slotCount":2,"sessionCount":0,
"osInfo":{"name":"Linux","arch":"aarch64","version":"7.0.14-linuxkit"}}]}}}
Source: Selenium — GraphQL query support
With one session open, { grid { sessionCount, maxSession }, sessionsInfo { sessions { id, nodeUri, ... } } } returned
sessionCount 1 and the session id; stereotypes is returned as a string containing JSON, not as an object, which is easy to
miss. Other documented queries: { grid { sessionQueueSize } } for the queue length, { sessionsInfo { sessionQueueRequests } }
for the queued requests themselves, { nodesInfo { nodes { stereotypes } } } for the capabilities of every Node, and
session (id: "<session-id>") { ... } for one session. Metadata a test sets with an se: capability, for example
se:name, appears in the session capabilities (see the RemoteWebDriver section).
Observability
Selenium Server is instrumented with OpenTelemetry. Every request is traced from start to end; a trace is made of timed
spans, a span has attributes, and most spans carry two kinds of events: a normal event that records a unit of work and its
success, and an error event with the exception information. Tracing is on by default (--tracing true) and works in every
role. Traces are exported in two ways: to the console at FINE level (the default log level is INFO, so raise it with
--log-level FINE) and to a tracing backend such as Jaeger, whose setup java -jar selenium-server-4.51.0.jar info tracing
prints.
java -jar selenium-server-4.51.0.jar standalone --log-level FINE
Events can also be logged as structured JSON (--structured-logs true; pretty-printing is off). Every event record has the
event time in epoch nanoseconds, the trace id and span id, the span kind, the event name and the event attributes (such as
http.status_code, http.handler_class, http.url, http.method, session.id); an error event adds exception.type,
exception.message and exception.stacktrace. Tracing must stay enabled for event logging even if you never export traces.
The official page shows a sample record for a DELETE /session/<id> and one for an exception from a misconfigured JDBC session
map. Neither this logging nor Jaeger were run for this page (no Jaeger container was started); the standard logging lines
quoted earlier are real.
Customising a Node
To add set-up before a session and clean-up after it (or any other decoration), write a class that extends
org.openqa.selenium.grid.node.Node, give it a static factory public static Node create(Config config), and pass its fully
qualified name with --node-implementation. The usual pattern is a wrapper: create the default LocalNode with
LocalNodeFactory.create(config) inside your factory and delegate every method to it, adding behaviour around the calls.
The documentation’s sample, DecoratedLoggingNode, prints a line before and after each operation (newSession,
executeWebDriverCommand, getSession, uploadFile, downloadFile, stop, isSessionOwner, isSupporting, getStatus,
drain, isReady and the rest). The core of it is:
public class DecoratedLoggingNode extends Node {
private Node node;
protected DecoratedLoggingNode(Tracer tracer, NodeId nodeId, URI uri, Secret registrationSecret, Duration sessionTimeout) {
super(tracer, nodeId, uri, registrationSecret, sessionTimeout);
}
public static Node create(Config config) {
LoggingOptions loggingOptions = new LoggingOptions(config);
BaseServerOptions serverOptions = new BaseServerOptions(config);
URI uri = serverOptions.getExternalUri();
SecretOptions secretOptions = new SecretOptions(config);
NodeOptions nodeOptions = new NodeOptions(config);
Duration sessionTimeout = nodeOptions.getSessionTimeout();
Node node = LocalNodeFactory.create(config); // the real Node that does the work
DecoratedLoggingNode wrapper = new DecoratedLoggingNode(loggingOptions.getTracer(), node.getId(), uri,
secretOptions.getRegistrationSecret(), sessionTimeout);
wrapper.node = node;
return wrapper;
}
@Override
public Either<WebDriverException, CreateSessionResponse> newSession(CreateSessionRequest sessionRequest) {
return perform(() -> node.newSession(sessionRequest), "newSession");
}
// ... every other abstract method of Node delegates the same way
}
Source: Selenium — Customizing a Node
Package it in one of two ways. As an uber jar (use the Maven Shade plugin rather than the Assembly plugin, which does not
merge the META-INF/services files properly), started with java -jar custom_node-server.jar node --node-implementation org.seleniumhq.samples.DecoratedLoggingNode.
Or as a regular jar added to the stock server: java -jar selenium-server-4.51.0.jar --ext custom_node-1.0-SNAPSHOT.jar node --node-implementation org.seleniumhq.samples.DecoratedLoggingNode.
The project needs the org.seleniumhq.selenium:selenium-grid dependency. The documentation also points to a second implementation,
org.openqa.selenium.grid.node.k8s.OneShotNode, that shuts itself down after one session; it is not in any pre-built Maven artifact,
so it has to be built from the source. This section was not run: the interfaces of Node are an internal API of Selenium
Server that can change between releases, so the sample must be compiled against the same version as the server that loads it.
External datastore
The Session Map is in memory by default. To survive a restart or share state among several Routers and Distributors you can
keep it in Redis or a SQL database (any with a JDBC driver; the page uses PostgreSQL). Both need the extra jar on the class
path of the sessions component through --ext; Coursier (coursier fetch -p ...) is the page’s way to fetch a Maven artifact and its dependencies as a class path.
# sessions.toml: Redis
[sessions]
scheme = "redis"
implementation = "org.openqa.selenium.grid.sessionmap.redis.RedisBackedSessionMap"
hostname = "localhost"
port = 6379
Source: Selenium — External datastore
# sessions.toml: PostgreSQL through JDBC
[sessions]
implementation = "org.openqa.selenium.grid.sessionmap.jdbc.JdbcBackedSessionMap"
jdbc-url = "jdbc:postgresql://localhost:5432/selenium_sessions"
jdbc-user = "seluser"
jdbc-password = "seluser"
Source: Selenium — External datastore
CREATE TABLE IF NOT EXISTS sessions_map(
session_ids varchar(256),
session_caps text,
session_uri varchar(256),
session_stereotype text,
session_start varchar(256)
);
Source: Selenium — External datastore
export SE_VERSION=4.51.0
java -jar selenium-server-${SE_VERSION}.jar \
--ext $(coursier fetch -p org.seleniumhq.selenium:selenium-session-map-redis:${SE_VERSION}) \
sessions --publish-events tcp://localhost:4442 --subscribe-events tcp://localhost:4443 \
--port 5556 --config sessions.toml
# JDBC variant: add the database driver to --ext
# --ext $(coursier fetch -p org.seleniumhq.selenium:selenium-session-map-jdbc:${SE_VERSION} org.postgresql:postgresql:42.3.1)
Source: Selenium — External datastore
The rest of the distributed Grid starts as shown in the roles section, with the Distributor and Router pointing at this
Session Map (--sessions http://localhost:5556). The official page keeps the database credentials in the file for the sake
of the example; use secrets for anything real. Recent versions also list --distributor-backend-url and --sessionqueue-backend-url
(for example redis://localhost:6379) for Redis-backed Distributor and New Session Queue implementations loaded with --ext.
None of this was run: it needs a Redis or PostgreSQL service and the Coursier tool, which were not set up on the test machine.
docker-selenium
The Selenium project publishes Grid images from the SeleniumHQ/docker-selenium repository. They are on Docker Hub
(selenium/*) and, since April 2026, mirrored to the GitHub Container Registry under ghcr.io/seleniumhq. All the images
used here ran at tag 4.51.0; dated tags of the form 4.51.0-20261010 pin a build exactly.
| Image | What it contains |
|---|---|
|
A Standalone Grid and one browser (or all of them) in a container. Port 4444 for WebDriver and the UI, 7900 for noVNC, 5900 for VNC. |
|
The Hub: Router, Distributor, Session Map, queue and Event Bus. Ports 4444, 4442 and 4443. |
|
A Node with one browser. Registers with the Hub through |
|
One component each, for the fully distributed role. |
|
The recorder sidecar ( |
|
A Standalone or a Node that starts a browser container for each session (Dynamic Grid). |
Source: docker-selenium
Give every browser container a shared memory size of at least 2 GB (--shm-size="2g", or shm_size: 2gb in Compose) so
the browser does not crash on its small default /dev/shm; the standalone container, for example, is started with:
docker run -d -p 4444:4444 -p 7900:7900 --shm-size="2g" selenium/standalone-chrome:4.51.0
Only one Standalone container can use host port 4444 at a time, so the examples on this page map the Grid to other host
ports. The Selenium environment variables (SE_NODE_MAX_SESSIONS, SE_OPTS, SE_JAVA_OPTS, SE_BROWSER_ARGS_*,
SE_SCREEN_WIDTH, SE_SCREEN_HEIGHT and so on) are listed in the repository’s ENV_VARIABLES.md. SE_OPTS passes any
extra flag to the Selenium Server, for example -e SE_OPTS="--session-timeout 600".
One Compose file for the topologies
The following Compose file defines every topology in this section as a profile; docker compose up -d with a profile selects one.
The project name sel-grid keeps its containers and network apart from other Compose projects. host.docker.internal
is the name by which a container reaches an application on the host (Docker Desktop defines it; the extra_hosts line with
host-gateway defines it on Linux). In the Java and Python examples the application URL is therefore
http://host.docker.internal:3916, not localhost: "localhost" inside the browser container is the container itself.
name: sel-grid
x-host: &host
extra_hosts:
- "host.docker.internal:host-gateway"
services:
# --- Standalone: every component in one container ---------------------
standalone:
profiles: [standalone]
image: selenium/standalone-chrome:4.51.0
shm_size: 2gb
<<: *host
environment:
- SE_NODE_MAX_SESSIONS=2
- SE_VNC_NO_PASSWORD=1
ports:
- "4601:4444" # WebDriver and Grid UI
- "7960:7900" # noVNC
# --- Hub and Nodes ------------------------------------------------------
hub:
profiles: [hubnode]
image: selenium/hub:4.51.0
ports:
- "4602:4444"
chrome:
profiles: [hubnode]
image: selenium/node-chrome:4.51.0
shm_size: 2gb
depends_on: [hub]
<<: *host
environment:
- SE_EVENT_BUS_HOST=hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=2
- SE_VNC_NO_PASSWORD=1
ports:
- "7961:7900"
firefox:
profiles: [hubnode]
image: selenium/node-firefox:4.51.0
shm_size: 2gb
depends_on: [hub]
<<: *host
environment:
- SE_EVENT_BUS_HOST=hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=2
- SE_VNC_NO_PASSWORD=1
ports:
- "7962:7900"
# --- Video sidecar: one recorder per browser container (1-1) -----------
chrome_video:
profiles: [video]
image: selenium/video:ffmpeg-8.1-20261010
depends_on: [chrome]
volumes:
- ./videos:/videos
environment:
- DISPLAY_CONTAINER_NAME=chrome
- SE_NODE_GRID_URL=http://hub:4444
- SE_VIDEO_FILE_NAME=auto
# --- Fully distributed: every component in its own container ----------
event-bus:
profiles: [distributed]
image: selenium/event-bus:4.51.0
sessions:
profiles: [distributed]
image: selenium/sessions:4.51.0
depends_on: [event-bus]
environment:
- SE_EVENT_BUS_HOST=event-bus
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
queue:
profiles: [distributed]
image: selenium/session-queue:4.51.0
distributor:
profiles: [distributed]
image: selenium/distributor:4.51.0
depends_on: [event-bus, sessions, queue]
environment:
- SE_EVENT_BUS_HOST=event-bus
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_SESSIONS_MAP_HOST=sessions
- SE_SESSIONS_MAP_PORT=5556
- SE_SESSION_QUEUE_HOST=queue
- SE_SESSION_QUEUE_PORT=5559
router:
profiles: [distributed]
image: selenium/router:4.51.0
depends_on: [distributor, sessions, queue]
ports:
- "4603:4444"
environment:
- SE_DISTRIBUTOR_HOST=distributor
- SE_DISTRIBUTOR_PORT=5553
- SE_SESSIONS_MAP_HOST=sessions
- SE_SESSIONS_MAP_PORT=5556
- SE_SESSION_QUEUE_HOST=queue
- SE_SESSION_QUEUE_PORT=5559
dchrome:
profiles: [distributed]
image: selenium/node-chrome:4.51.0
shm_size: 2gb
depends_on: [event-bus]
<<: *host
environment:
- SE_EVENT_BUS_HOST=event-bus
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=2
Each profile was started, tested and removed with docker compose --profile <name> up -d and
docker compose --profile <name> down -v on Docker 29 with Compose 5.6 (macOS, Apple silicon, so the images ran as
linux/arm64). Results:
| Profile | What /status and the tests showed |
Notes |
|---|---|---|
|
|
The Firefox failure is the point: the answer was |
|
|
|
|
A |
See the video section for what was and was not recorded. |
|
|
Six containers: Event Bus, Session Map, queue, Distributor, Router and one Chrome Node. |
Watching a session: noVNC and VNC
The browser images run the browser in a virtual display and serve it two ways: a VNC server on port 5900 and a browser-based
noVNC client on port 7900 (--no-vnc-port, SE_NO_VNC_PORT). Publish the port you need and open
http://localhost:7900/?autoconnect=1&resize=scale&password=secret while a test is running; secret is the default
VNC password, and SE_VNC_NO_PASSWORD=1 removes it (used in the Compose file above, for a local Grid only) while
SE_VNC_PASSWORD sets another. The Grid UI links a running session to the same view. The Node reports se:vncEnabled and se:noVncPort in the slot
stereotype, which is visible in the /status output. With several
Nodes on one host, map each container to a different host port. For the Compose file above, the noVNC pages answered HTTP 200
on 7960 (Standalone), 7961 (Chrome Node) and 7962 (Firefox Node). Viewing needs a person, so the picture itself was not
inspected. The noVNC view is a debugging tool: it shows the real browser, headed, so a test that is flaky only in the
Grid can be watched while it runs. Headless browsers have no display to show, and video recording does not support them.
Recording video
A recording needs a second container, selenium/video, in front of every browser container (one to one, so five Nodes
need five recorders). It watches the browser’s virtual display with ffmpeg and writes MP4 files to /videos, so mount a
host directory there. Expect about one CPU for each recorder and one for each browser. The Compose service above is the
recommended wiring: DISPLAY_CONTAINER_NAME names the Node container whose display is recorded, SE_NODE_GRID_URL lets the
recorder ask the Hub which session is active, and SE_VIDEO_FILE_NAME=auto names each file from the se:name capability
of the session plus the session id.
The file names show it working: the tests set se:name to paginationShowsSixBooksPerPage [chrome] and the recorder produced
paginationShowsSixBooksPerPage_chrome_18d3c45fc014cd072d0d339950f2538f.mp4 (spaces become underscores, characters outside
letters, digits, hyphen and underscore are removed, and the name is cut to 255 characters; SE_VIDEO_FILE_NAME_TRIM_REGEX changes
the pattern and SE_VIDEO_FILE_NAME_SUFFIX=false drops the session id). The files started with the standard MP4 ftyp header and
were 64 KB and 175 KB for sessions of a second or two.
Not every session was recorded in this run, and that is worth knowing before relying on video:
-
The Chrome Node had two slots, and the recorder films one display. When two Chrome sessions ran at the same time (the parallel JUnit run) only one produced a file. For video, run one session per recorded Node (
SE_NODE_MAX_SESSIONS=1). -
Of two sequential runs of two Chrome tests each lasting a second or two, only one test in each run produced a file. The recorder polls for sessions (
SE_VIDEO_POLL_INTERVAL, default 1 second here) when it is not in event-driven mode, so very short sessions can be missed. Real tests take longer, but a smoke test that finishes in under a second may not be filmed.
By default the recorder films every session (SE_RECORD_VIDEO is true in the video image). A test can decide for itself with the
se:recordVideo capability, which wins over the variable for that session: set SE_RECORD_VIDEO=false and opt in per test, or
leave it on and send false for the sessions that do not need a video. The capability also works in event-driven mode, where the
recorder subscribes to the Event Bus instead of polling. Videos can be uploaded (SE_VIDEO_UPLOAD_ENABLED) or kept only for failed sessions, which the repository documents; neither was tried here.
For a Dynamic Grid the recorder image is set with video-image under [docker] (or --docker-video-image) and the Node starts
one recorder for each browser container. The older, separate way of getting evidence, a screenshot on failure taken by the
test, is explained in Screenshots, Videos & Traces and does not need a
sidecar.
Dynamic Grid
A Dynamic Grid starts a Docker container for each session request and removes it after the test. It needs a [docker]
section that maps images to stereotypes, and the address of the Docker daemon. The official standalone-docker image can act
as a Standalone or node-docker as a Node for a Hub. With the Docker socket mounted, the image exposes the daemon on
http://127.0.0.1:2375 through socat. This configuration ran on the test host:
[docker]
# Image to start for each new session, and the capabilities that select it
configs = [
"selenium/standalone-chrome:4.51.0", '{"browserName": "chrome"}',
"selenium/standalone-firefox:4.51.0", '{"browserName": "firefox"}'
]
# Pass the host name mapping on to the browser containers
host-config-keys = ["Dns", "DnsOptions", "DnsSearch", "ExtraHosts", "Binds"]
# Mounting /var/run/docker.sock makes the container expose the daemon on this URL
url = "http://127.0.0.1:2375"
[node]
max-sessions = 2
Source: docker-selenium — Dynamic Grid
name: sel-grid-dynamic
services:
dynamic:
image: selenium/standalone-docker:4.51.0
container_name: sel-grid-dynamic
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- SE_NODE_DOCKER_CONFIG_FILENAME=docker.toml
volumes:
- ./dynamic.toml:/opt/selenium/docker.toml
- ./assets:/opt/selenium/assets
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "4604:4444"
Source: docker-selenium — Dynamic Grid
The Grid reported four slots (Chrome and Firefox, twice each), and the Python search test passed for both browsers in 24 seconds,
including the time to start a container per session; the browser containers (named browser-chrome-...) were gone
after the sessions ended. Three details cost time and are not obvious from the documentation:
-
The configuration file has to be mounted at
/opt/selenium/docker.toml, not atconfig.toml, together withSE_NODE_DOCKER_CONFIG_FILENAME=docker.toml. When it was mounted asconfig.toml, the image ignored it and used a generated default file, which on this host named the image tags4.48.0-20260905, and the server failed to start with "no matching manifest for linux/arm64 in the manifest list entries" because that tag has no arm64 image. -
An assets directory must be mounted at
/opt/selenium/assets. Without it every session failed with "Assets path must be set" and the client saw a session-not-created error. -
The browser containers need the same
host.docker.internalmapping as other containers, whichhost-config-keyswithExtraHostspasses down; give the standalone container itself anextra_hostsentry on Linux.
Mounting the Docker socket gives the container full control of the host’s Docker daemon: use a Dynamic Grid only in a trusted environment.
Helm chart and KEDA autoscaling
For Kubernetes the project offers a Helm chart that deploys the Grid (and, optionally, scales it). Add the repository and install:
helm repo add docker-selenium https://www.selenium.dev/docker-selenium
helm repo update
helm install selenium-grid docker-selenium/selenium-grid
helm install selenium-grid docker-selenium/selenium-grid --set isolateComponents=true # one Pod per component
helm install selenium-grid docker-selenium/selenium-grid --version <version>
Autoscaling uses KEDA with its selenium-grid scaler, which reads the number of pending and running sessions from the Grid’s
GraphQL endpoint and scales browser Node Deployments or Jobs up and down. Enable it with autoscaling.enabled (the chart then
installs KEDA) or autoscaling.enableWithExistingKEDA (use a KEDA you already have). autoscaling.scalingType selects between
deployment (a ScaledObject: long-running Nodes scaled by replica count, with a terminationGracePeriodSeconds of 3600 seconds
by default so running tests finish) and job (a ScaledJob: one short-lived Node for each session, which is a close cousin of
a Dynamic Grid). The trigger metadata browserVersion and platformName should be set explicitly for correct scaling when
the Grid mixes autoscaled and fixed Nodes, and the client request should use the same values. The chart can also deploy an
external scaler that the Selenium project maintains separately from KEDA’s built-in one. The Distributor works hard during
mass scale-up, so the chart sets the new-session thread pool (SE_NEW_SESSION_THREAD_POOL_SIZE, behind --newsession-threadpool-size)
from the number of node types and the maximum replica count.
The Helm chart and KEDA were documented only and not run: this test machine has no Kubernetes cluster, Helm or KEDA, and a local cluster was out of scope. Treat the values above as pointers to the chart’s README and CONFIGURATION files, which are long and change with the chart version.
The GHCR mirror
Since April 2026 the official Grid images are also available from the GitHub Container Registry, under ghcr.io/seleniumhq
(not ghcr.io/selenium). It is a mirror, not a replacement; Docker Hub keeps working and the naming is the same with a
different registry prefix. Use it where Docker Hub is blocked by policy, rate-limited or unauthenticated:
docker pull ghcr.io/seleniumhq/standalone-chrome:4.51.0
docker run -d -p 4444:4444 ghcr.io/seleniumhq/standalone-chrome:latest
docker pull ghcr.io/seleniumhq/node-chrome:nightly
For this page the GHCR tags were checked with docker manifest inspect ghcr.io/seleniumhq/standalone-chrome:4.51.0, which
returned an OCI image index with an amd64 Linux manifest (the images themselves were pulled from Docker Hub, so the
containers on this page did not run from GHCR).
Running tests through RemoteWebDriver
A test uses the Grid by building a RemoteWebDriver with the Grid URL and the options of the browser it wants. Everything
after that, locators, waits, assertions, is the same as with a local driver, as the tests below show (they use the Bookshelf
selectors from Example Application & Test Layout and the explicit waits of
Waits & Expected Conditions). The Options classes of
Drivers, Browsers & Options are what the Grid matches against the stereotypes of its slots. Do not
set driver executable paths: the Node owns the driver.
| Binding | Remote driver |
|---|---|
Java |
|
Python |
|
C# |
|
Ruby |
|
JavaScript |
|
Source: Selenium — Remote WebDriver
The C#, Ruby and JavaScript lines are the documented forms and were not run (no .NET SDK, Ruby or Node Selenium run was made against a Grid for this page).
Java and JUnit 5
The first example is a parameterised JUnit 5 test class that runs the same test on Chrome and on Firefox. Two details matter for
parallel runs: the driver of each test lives in a ThreadLocal, so concurrent tests in the same JVM never share a
session, and the application URL is the address of Bookshelf as the browser sees it (host.docker.internal), while the Grid
URL is the address as the test sees it. Both come from system properties so a pipeline can override them.
package demo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.time.Duration;
import java.util.Locale;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.openqa.selenium.By;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.MutableCapabilities;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
class GridCrossBrowserTest {
// Where the tests talk to the Grid, and where the *browser* finds the application.
static final String GRID_URL = System.getProperty("grid.url", "http://localhost:4602");
static final String APP_URL = System.getProperty("app.url", "http://host.docker.internal:3916");
private final ThreadLocal<RemoteWebDriver> driver = new ThreadLocal<>();
static MutableCapabilities optionsFor(String browser) {
return switch (browser) {
case "chrome" -> new ChromeOptions();
case "firefox" -> new FirefoxOptions();
default -> throw new IllegalArgumentException(browser);
};
}
RemoteWebDriver start(String browser, String testName) throws Exception {
MutableCapabilities options = optionsFor(browser);
options.setCapability("se:name", testName + " [" + browser + "]"); // shown in the Grid UI
RemoteWebDriver d = new RemoteWebDriver(URI.create(GRID_URL).toURL(), options);
driver.set(d);
return d;
}
@AfterEach
void stop() {
RemoteWebDriver d = driver.get();
if (d != null) {
d.quit();
driver.remove();
}
}
@ParameterizedTest(name = "search on {0}")
@ValueSource(strings = {"chrome", "firefox"})
void searchFindsBook(String browser) throws Exception {
WebDriver d = start(browser, "searchFindsBook");
d.get(APP_URL + "/books");
d.findElement(By.cssSelector("[data-testid=search-input]")).sendKeys("quiet");
d.findElement(By.cssSelector("[data-testid=search-submit]")).click();
new WebDriverWait(d, Duration.ofSeconds(10)).until(ExpectedConditions.textToBePresentInElementLocated(
By.cssSelector("[data-testid=result-count]"), "1 book found"));
assertEquals(1, d.findElements(By.cssSelector("[data-testid=book-card]")).size());
// Which browser did the Grid really give us?
Capabilities actual = driver.get().getCapabilities();
assertEquals(browser, actual.getBrowserName().toLowerCase(Locale.ROOT));
System.out.printf("%s ran on %s %s (%s) in session %s%n", Thread.currentThread().getName(),
actual.getBrowserName(), actual.getBrowserVersion(), actual.getPlatformName(),
driver.get().getSessionId());
}
@ParameterizedTest(name = "pagination on {0}")
@ValueSource(strings = {"chrome", "firefox"})
void paginationShowsSixBooksPerPage(String browser) throws Exception {
WebDriver d = start(browser, "paginationShowsSixBooksPerPage");
d.get(APP_URL + "/books");
assertEquals(6, d.findElements(By.cssSelector("[data-testid=book-card]")).size());
d.findElement(By.cssSelector("[data-testid=page-next]")).click();
new WebDriverWait(d, Duration.ofSeconds(10)).until(ExpectedConditions.textToBePresentInElementLocated(
By.cssSelector("[data-testid=page-indicator]"), "2"));
assertTrue(d.getCurrentUrl().contains("page=2"), d.getCurrentUrl());
}
}
Source: Selenium — Remote WebDriver
JUnit 5 runs methods one after the other by default. Switching on its parallel execution takes a properties file on the test class
path (src/test/resources/junit-platform.properties); with a fixed parallelism of 4, two concurrent Chrome tests fit the Hub’s Chrome Node (2 slots)
and two concurrent Firefox tests fit the Firefox Node.
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
Source: JUnit 5 — Parallel execution
$ mvn -B test -Dgrid.url=http://localhost:4602
ForkJoinPool-1-worker-5 ran on chrome 155.0.8059.79 (linux) in session 93ec4f79ca4a6b03684eb7943a7a10ce
ForkJoinPool-1-worker-2 ran on firefox 158.0 (linux) in session e7666fa4-2471-47ba-8db4-cf840f3f7abc
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.888 s -- in demo.GridCrossBrowserTest
[INFO] BUILD SUCCESS
$ mvn -B test -Dgrid.url=http://localhost:4601 # Standalone Chrome only
[ERROR] Tests run: 4, Failures: 0, Errors: 2, Skipped: 0 # the two Firefox cases: "No nodes support the capabilities in the request"
The four tests (two scenarios, two browsers) passed in 3.9 seconds on workers of the ForkJoinPool, with a session id for
Firefox in the UUID form that geckodriver uses and a 32-hex one for Chrome. With the parallel property switched off
(-Djunit.jupiter.execution.parallel.enabled=false) the same tests passed one after the other. The two scenarios only
read data, which is why they need no reset between them; tests that sign in or add reviews must either seed data
through the API per test or run with workers equal to one, because Bookshelf’s /api/test/reset clears every server session.
Python and pytest
A pytest fixture with params gives the same cross-browser matrix. se:name shows up in the Grid UI and, with the recorder,
in the video file name.
# tests/conftest.py
import os
import pytest
from selenium import webdriver
# Where the tests talk to the Grid, and where the *browser* finds the application.
GRID_URL = os.environ.get("GRID_URL", "http://localhost:4602")
APP_URL = os.environ.get("APP_URL", "http://host.docker.internal:3916")
OPTIONS = {
"chrome": webdriver.ChromeOptions,
"firefox": webdriver.FirefoxOptions,
"edge": webdriver.EdgeOptions,
}
@pytest.fixture
def app_url():
return APP_URL
@pytest.fixture(params=os.environ.get("BROWSERS", "chrome,firefox").split(","))
def driver(request):
"""A fresh remote browser per test, one for each browser in BROWSERS."""
options = OPTIONS[request.param]()
options.set_capability("se:name", f"{request.node.name}") # shown in the Grid UI
d = webdriver.Remote(command_executor=GRID_URL, options=options)
yield d
d.quit()
Source: Selenium — Remote WebDriver
# tests/test_grid.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
def test_search_finds_book(driver, app_url):
driver.get(f"{app_url}/books")
driver.find_element(By.CSS_SELECTOR, "[data-testid=search-input]").send_keys("quiet")
driver.find_element(By.CSS_SELECTOR, "[data-testid=search-submit]").click()
WebDriverWait(driver, 10).until(EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, "[data-testid=result-count]"), "1 book found"))
assert len(driver.find_elements(By.CSS_SELECTOR, "[data-testid=book-card]")) == 1
caps = driver.capabilities
print(caps["browserName"], caps["browserVersion"], caps["platformName"], driver.session_id)
def test_pagination_shows_six_books_per_page(driver, app_url):
driver.get(f"{app_url}/books")
assert len(driver.find_elements(By.CSS_SELECTOR, "[data-testid=book-card]")) == 6
driver.find_element(By.CSS_SELECTOR, "[data-testid=page-next]").click()
WebDriverWait(driver, 10).until(EC.url_contains("page=2"))
Source: Selenium — Remote WebDriver
pytest runs tests sequentially. pytest-xdist (3.8.0 here) distributes them over worker processes, and because each test
owns its driver the Grid sees concurrent, independent sessions. Without xdist, a ThreadPoolExecutor around the same driver
code gives the same effect for a script; the queue demonstration below uses threads.
$ .venv/bin/python -m pytest tests -v -s
tests/test_grid.py::test_search_finds_book[chrome] chrome 155.0.8059.79 linux 25788a4916695583a452ab76230b4264
PASSED
tests/test_grid.py::test_search_finds_book[firefox] firefox 158.0 linux b410f3bf-af15-41d5-a858-4f44d609d2e2
PASSED
tests/test_grid.py::test_pagination_shows_six_books_per_page[chrome] PASSED
tests/test_grid.py::test_pagination_shows_six_books_per_page[firefox] PASSED
============================== 4 passed in 3.90s ===============================
$ .venv/bin/python -m pytest tests -n 4
4 workers [4 items]
.... [100%]
============================== 4 passed in 2.87s ===============================
The same file passed against the distributed Grid through the Router (GRID_URL=http://localhost:4603 BROWSERS=chrome, two
tests) and against the Dynamic Grid (GRID_URL=http://localhost:4604, the search test on both browsers). Note that the
pytest summary line reports 3.9 seconds when run serially and 2.9 with four workers: for four short tests most of the
time is session creation, so parallelism gains little until the tests are longer than a few seconds each.
What happens when the Grid is full
A Grid with fewer free slots than requests does not fail them: it queues them (up to the request timeout). This script opens two Chrome sessions on the Hub’s Chrome Node, asks for a third in a thread, and uses GraphQL to look at the queue. The third session is served as soon as one of the others quits:
import json
import threading
import time
import urllib.request
from selenium import webdriver
GRID = "http://localhost:4602"
def grid_numbers():
request = urllib.request.Request(
f"{GRID}/graphql",
data=json.dumps({"query": "{ grid { sessionCount, sessionQueueSize } }"}).encode(),
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(request))["data"]["grid"]
# The Chrome Node has two slots: two sessions fill it, the third has to wait in the queue.
first, second = (webdriver.Remote(GRID, options=webdriver.ChromeOptions()) for _ in range(2))
result = {}
def third():
started = time.time()
result["driver"] = webdriver.Remote(GRID, options=webdriver.ChromeOptions())
result["waited"] = time.time() - started
thread = threading.Thread(target=third)
thread.start()
time.sleep(3)
print("while queued:", grid_numbers())
first.quit() # frees a slot, the queued request is served
thread.join()
print(f"third waited {result['waited']:.1f}s")
print("after:", grid_numbers())
second.quit()
result["driver"].quit()
while queued: {'sessionCount': 2, 'sessionQueueSize': 1}
third waited 3.3s
after: {'sessionCount': 2, 'sessionQueueSize': 0}
The practical consequence is that parallelism in the test runner can safely be higher than the number of slots, at the price of
the waiting time that shows up as a long session start; and that the client’s HTTP timeout, not just the Grid’s
--session-request-timeout, can end a wait. Size the runner’s parallelism near the Grid’s capacity, and watch
sessionQueueSize in monitoring.
Capabilities that talk to the Grid
Capabilities with the se: prefix are for Grid and docker-selenium; the Grid ignores unknown ones for matching.
| Capability | Effect |
|---|---|
|
A test name shown instead of the session id in the Grid UI and used for the video file name when |
|
Record or skip the session in the docker-selenium recorder, overriding |
|
Ask the Node to keep the files the session downloads so the client can list, fetch and delete them ( |
|
Reported by Nodes that expose VNC. |
Any |
Metadata, visible in the session information and through GraphQL. |
Uploads and downloads cross the network when the browser is remote. An upload from a RemoteWebDriver needs a file
detector so that sendKeys with a local path sends the file to the Node (Java has none by default, so call
setFileDetector(new LocalFileDetector()); some other bindings include a basic one by default).
A download is kept on the Node, so request it with se:downloadsEnabled and fetch it through HasDownloads in Java
(getDownloadableFiles, downloadFile, deleteDownloadableFiles) or the /session/<id>/se/files endpoint of the Grid. Neither was run:
the Bookshelf application has no upload or download feature.
WebDriver BiDi and CDP also flow through the Grid as web sockets: the Router opens a TCP tunnel (--tcp-tunnel) so the client
connects to the Node. See WebDriver BiDi for what to do with them. If a firewall,
proxy or ingress does not carry web sockets, disable them with --enable-cdp false and --enable-bidi false.
Pitfalls
-
localhostin the browser means the browser’s machine. Give containershost.docker.internal(withhost-gatewayon Linux) or a name on the Docker network; never the URL that works from your IDE. -
A request for a browser the Grid does not have fails at once with "No nodes support the capabilities in the request", as the Firefox cases against the Chrome Standalone showed.
--reject-unsupported-capsis the documented switch for this behaviour on Grids that create Nodes on demand. -
totalSlotsis not capacity. ReadmaxSessionfor how many sessions can run at once. -
Release the session in a
finally,@AfterEachor fixture finalizer. A session that is never quit keeps its slot until--session-timeout(300 seconds by default) expires. -
The Grid in a container must be told its own external address when clients cannot reach the container IP (
SE_NODE_GRID_URL,--external-url,--grid-url), and Node containers needSE_EVENT_BUS_HOSTand both Event Bus ports. -
Keep the Grid and the bindings on the same version where you can. Pin the images to a tag and update them together with the bindings, so a new
se:capability or protocol change is available on both sides. -
On Apple silicon the images run as
linux/arm64. Some default tags that ship inside images may have no arm64 build, as the Dynamic Grid section shows. -
Keep a Grid private. See the security section.
What changed recently
| Version or date | Change |
|---|---|
April 2026 |
Official Grid images are mirrored to |
4.51.0 (9 October 2026) |
Current release at the check date for the Selenium Server and the bindings. docker-selenium published |
4.48 and later |
The CLI reference was updated on 3 September 2026 with the options introduced in 4.48. The reference now lists the
Kubernetes options ( |
Per-session video control |
The |
Helm and KEDA |
The chart can use KEDA’s built-in |
4.5 |
The JDK HTTP client can replace AsyncHttpClient in the Grid. |
4.0 |
Grid rebuilt as Router, Distributor, Session Map, New Session Queue, Node and Event Bus, with Standalone, Hub and Node, and distributed roles, GraphQL, OpenTelemetry tracing and Docker-based Nodes. |